From 032e187681581eb24049f2482149da1d3d47159b Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Sat, 9 Jan 2021 17:26:51 -0800 Subject: [PATCH] Edit attachments --- Extensions/UIView+Extensions.swift | 28 +++ Localizations/Localizable.strings | 5 + .../Mastodon/Entities/Attachment.swift | 8 +- .../Endpoints/AttachmentEndpoint.swift | 17 ++ Metatext.xcodeproj/project.pbxproj | 24 ++ .../Services/IdentityService.swift | 6 + .../EditAttachmentViewController.swift | 127 ++++++++++ .../NewStatusViewController.swift | 15 +- .../ViewModels/AttachmentViewModel.swift | 23 ++ .../ViewModels/CompositionViewModel.swift | 33 ++- .../ViewModels/NewStatusViewModel.swift | 19 +- Views/AttachmentView.swift | 24 +- Views/EditAttachmentView.swift | 18 ++ Views/EditThumbnailView.swift | 218 ++++++++++++++++++ Views/TabNavigationView.swift | 2 +- Views/ViewConstants.swift | 2 +- 16 files changed, 534 insertions(+), 35 deletions(-) create mode 100644 Extensions/UIView+Extensions.swift create mode 100644 View Controllers/EditAttachmentViewController.swift create mode 100644 Views/EditAttachmentView.swift create mode 100644 Views/EditThumbnailView.swift diff --git a/Extensions/UIView+Extensions.swift b/Extensions/UIView+Extensions.swift new file mode 100644 index 0000000..6bdcb0b --- /dev/null +++ b/Extensions/UIView+Extensions.swift @@ -0,0 +1,28 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Mastodon +import UIKit + +extension UIView { + private static let defaultContentsRectSize = CGSize(width: 1, height: 1) + + func setContentsRect(focus: Attachment.Meta.Focus, mediaSize: CGSize) { + let aspectRatio = mediaSize.width / mediaSize.height + let viewAspectRatio = bounds.width / bounds.height + var origin = CGPoint.zero + + if viewAspectRatio > aspectRatio { + let mediaProportionalHeight = mediaSize.height * bounds.width / mediaSize.width + let maxPan = (mediaProportionalHeight - bounds.height) / (2 * mediaProportionalHeight) + + origin.y = CGFloat(-focus.y) * maxPan + } else { + let mediaProportionalWidth = mediaSize.width * bounds.height / mediaSize.height + let maxPan = (mediaProportionalWidth - bounds.width) / (2 * mediaProportionalWidth) + + origin.x = CGFloat(focus.x) * maxPan + } + + layer.contentsRect = CGRect(origin: origin, size: Self.defaultContentsRectSize) + } +} diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 65a2917..4c3507f 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -31,6 +31,11 @@ "add-identity.join" = "Join"; "add-identity.request-invite" = "Request an invite"; "add-identity.unable-to-connect-to-instance" = "Unable to connect to instance"; +"attachment.edit.description" = "Describe for the visually impaired"; +"attachment.edit.description.audio" = "Describe for people with hearing loss"; +"attachment.edit.description.video" = "Describe for people with hearing loss or visual impairment"; +"attachment.edit.title" = "Edit media"; +"attachment.edit.thumbnail.prompt" = "Drag the circle on the preview to choose the focal point which will always be in view on all thumbnails"; "attachment.sensitive-content" = "Sensitive content"; "attachment.media-hidden" = "Media hidden"; "bookmarks" = "Bookmarks"; diff --git a/Mastodon/Sources/Mastodon/Entities/Attachment.swift b/Mastodon/Sources/Mastodon/Entities/Attachment.swift index eee7709..eb0b2eb 100644 --- a/Mastodon/Sources/Mastodon/Entities/Attachment.swift +++ b/Mastodon/Sources/Mastodon/Entities/Attachment.swift @@ -22,8 +22,8 @@ public struct Attachment: Codable, Hashable { } public struct Focus: Codable, Hashable { - public let x: Double - public let y: Double + public var x: Double + public var y: Double } public let original: Info? @@ -60,3 +60,7 @@ public extension Attachment { return nil } } + +public extension Attachment.Meta.Focus { + static let `default` = Self(x: 0, y: 0) +} diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/AttachmentEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/AttachmentEndpoint.swift index 8683d85..3edafc4 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoints/AttachmentEndpoint.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/AttachmentEndpoint.swift @@ -6,6 +6,7 @@ import Mastodon public enum AttachmentEndpoint { case create(data: Data, mimeType: String, description: String?, focus: Attachment.Meta.Focus?) + case update(id: Attachment.Id, description: String?, focus: Attachment.Meta.Focus?) } extension AttachmentEndpoint: Endpoint { @@ -19,6 +20,8 @@ extension AttachmentEndpoint: Endpoint { switch self { case .create: return [] + case let .update(id, _, _): + return [id] } } @@ -37,6 +40,18 @@ extension AttachmentEndpoint: Endpoint { params["focus"] = .string("\(focus.x),\(focus.y)") } + return params + case let .update(_, description, focus): + var params = [String: MultipartFormValue]() + + if let description = description { + params["description"] = .string(description) + } + + if let focus = focus { + params["focus"] = .string("\(focus.x),\(focus.y)") + } + return params } } @@ -45,6 +60,8 @@ extension AttachmentEndpoint: Endpoint { switch self { case .create: return .post + case .update: + return .put } } } diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 721f891..ea81787 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -36,6 +36,14 @@ D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03B1B29253818F3008F964B /* MediaPreferencesView.swift */; }; D04226FD2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */; }; D04F9E8E259E9C950081B0C9 /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D04F9E8D259E9C950081B0C9 /* ViewModels */; }; + D05936CF25A8D79800754FDF /* EditAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05936CE25A8D79800754FDF /* EditAttachmentViewController.swift */; }; + D05936D025A8D79800754FDF /* EditAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05936CE25A8D79800754FDF /* EditAttachmentViewController.swift */; }; + D05936DE25A937EC00754FDF /* EditThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05936DD25A937EC00754FDF /* EditThumbnailView.swift */; }; + D05936DF25A937EC00754FDF /* EditThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05936DD25A937EC00754FDF /* EditThumbnailView.swift */; }; + D05936E925AA3F3D00754FDF /* EditAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05936E825AA3F3D00754FDF /* EditAttachmentView.swift */; }; + D05936EA25AA3F3D00754FDF /* EditAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05936E825AA3F3D00754FDF /* EditAttachmentView.swift */; }; + D05936F425AA66A600754FDF /* UIView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05936F325AA66A600754FDF /* UIView+Extensions.swift */; }; + D05936F525AA66A600754FDF /* UIView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05936F325AA66A600754FDF /* UIView+Extensions.swift */; }; D0625E59250F092900502611 /* StatusListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E58250F092900502611 /* StatusListCell.swift */; }; D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */; }; D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BC5E525202AD90079541D /* ProfileViewController.swift */; }; @@ -181,6 +189,10 @@ D03B1B29253818F3008F964B /* MediaPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreferencesView.swift; sourceTree = ""; }; D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupAndSyncingPreferencesView.swift; sourceTree = ""; }; D047FA8C24C3E21200AF17C5 /* Metatext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metatext.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D05936CE25A8D79800754FDF /* EditAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAttachmentViewController.swift; sourceTree = ""; }; + D05936DD25A937EC00754FDF /* EditThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditThumbnailView.swift; sourceTree = ""; }; + D05936E825AA3F3D00754FDF /* EditAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAttachmentView.swift; sourceTree = ""; }; + D05936F325AA66A600754FDF /* UIView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Extensions.swift"; sourceTree = ""; }; D0625E58250F092900502611 /* StatusListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusListCell.swift; sourceTree = ""; }; D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentConfiguration.swift; sourceTree = ""; }; D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -442,7 +454,9 @@ D00702302555F4AE00F38136 /* ConversationView.swift */, D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */, D08E52602579D2E100FA2C5F /* DomainBlocksView.swift */, + D05936E825AA3F3D00754FDF /* EditAttachmentView.swift */, D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */, + D05936DD25A937EC00754FDF /* EditThumbnailView.swift */, D0BEB20424FA1107001B0F04 /* FiltersView.swift */, D0C7D42224F76169001EBDBB /* IdentitiesView.swift */, D0BEB1FE24F9E5BB001B0F04 /* ListsView.swift */, @@ -482,6 +496,7 @@ D0C7D43024F76169001EBDBB /* View Controllers */ = { isa = PBXGroup; children = ( + D05936CE25A8D79800754FDF /* EditAttachmentViewController.swift */, D08B8D49253FC36500B1EBEF /* ImageNavigationController.swift */, D08B8D41253F92B600B1EBEF /* ImagePageViewController.swift */, D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */, @@ -522,6 +537,7 @@ D0C7D46A24F76169001EBDBB /* String+Extensions.swift */, D08E512025786A6600FA2C5F /* UIButton+Extensions.swift */, D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */, + D05936F325AA66A600754FDF /* UIView+Extensions.swift */, D0E7AD3825870B13005F5E2D /* UIVIewController+Extensions.swift */, D0030981250C6C8500EACB32 /* URL+Extensions.swift */, D0C7D46F24F76169001EBDBB /* View+Extensions.swift */, @@ -760,6 +776,7 @@ D08E52EE257D757100FA2C5F /* CompositionView.swift in Sources */, D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */, D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */, + D05936DE25A937EC00754FDF /* EditThumbnailView.swift in Sources */, D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */, D007023E25562A2800F38136 /* ConversationAvatarsView.swift in Sources */, D0E7AD3925870B13005F5E2D /* UIVIewController+Extensions.swift in Sources */, @@ -789,6 +806,7 @@ D0C7D4DA24F7616A001EBDBB /* View+Extensions.swift in Sources */, D08B8D8D2544E6EC00B1EBEF /* PollResultView.swift in Sources */, D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */, + D05936CF25A8D79800754FDF /* EditAttachmentViewController.swift in Sources */, D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */, D00702362555F4C500F38136 /* ConversationContentConfiguration.swift in Sources */, D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */, @@ -803,6 +821,8 @@ D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */, D036AA17254CA824009094DF /* StatusBodyView.swift in Sources */, D08E512125786A6600FA2C5F /* UIButton+Extensions.swift in Sources */, + D05936F425AA66A600754FDF /* UIView+Extensions.swift in Sources */, + D05936E925AA3F3D00754FDF /* EditAttachmentView.swift in Sources */, D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */, D04226FD2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift in Sources */, D036AA0C254B612B009094DF /* NotificationContentConfiguration.swift in Sources */, @@ -847,20 +867,24 @@ D08E52A6257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift in Sources */, D08E52D2257C811200FA2C5F /* ShareExtensionError+Extensions.swift in Sources */, D0E9F9AB258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */, + D05936D025A8D79800754FDF /* EditAttachmentViewController.swift in Sources */, D038273C259EA38F00056E0F /* NewStatusView.swift in Sources */, D08E52EF257D757100FA2C5F /* CompositionView.swift in Sources */, D0CE9F88258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */, D08E52C7257C7AEE00FA2C5F /* ShareErrorViewController.swift in Sources */, D015B14425A812F6006D88A8 /* PlayerCache.swift in Sources */, + D05936F525AA66A600754FDF /* UIView+Extensions.swift in Sources */, D015B13F25A812EC006D88A8 /* PlayerView.swift in Sources */, D015B13A25A812E6006D88A8 /* AttachmentView.swift in Sources */, D08E52F8257D78BE00FA2C5F /* ViewConstants.swift in Sources */, D036EBC2259FE2AD00EC1CFC /* UIVIewController+Extensions.swift in Sources */, D015B13525A812DD006D88A8 /* AttachmentsView.swift in Sources */, + D05936EA25AA3F3D00754FDF /* EditAttachmentView.swift in Sources */, D0FCC106259C4E62000B67DF /* NewStatusViewController.swift in Sources */, D036EBBD259FE2A100EC1CFC /* Array+Extensions.swift in Sources */, D036EBB3259FE28800EC1CFC /* UIColor+Extensions.swift in Sources */, D036EBB8259FE29800EC1CFC /* Status+Extensions.swift in Sources */, + D05936DF25A937EC00754FDF /* EditThumbnailView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift index 607bbea..d22354f 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift @@ -212,6 +212,12 @@ public extension IdentityService { progress: progress) } + func updateAttachment(id: Attachment.Id, + description: String, + focus: Attachment.Meta.Focus) -> AnyPublisher { + mastodonAPIClient.request(AttachmentEndpoint.update(id: id, description: description, focus: focus)) + } + func post(statusComponents: StatusComponents) -> AnyPublisher { mastodonAPIClient.request(StatusEndpoint.post(statusComponents)).map(\.id).eraseToAnyPublisher() } diff --git a/View Controllers/EditAttachmentViewController.swift b/View Controllers/EditAttachmentViewController.swift new file mode 100644 index 0000000..d6fd3f4 --- /dev/null +++ b/View Controllers/EditAttachmentViewController.swift @@ -0,0 +1,127 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Combine +import UIKit +import ViewModels + +final class EditAttachmentViewController: UIViewController { + private let viewModel: AttachmentViewModel + private let parentViewModel: CompositionViewModel + private var cancellables = Set() + + init(viewModel: AttachmentViewModel, parentViewModel: CompositionViewModel) { + self.viewModel = viewModel + self.parentViewModel = parentViewModel + + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // swiftlint:disable:next function_body_length + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + let editThumbnailView = EditThumbnailView(viewModel: viewModel) + + view.addSubview(editThumbnailView) + editThumbnailView.translatesAutoresizingMaskIntoConstraints = false + + let stackView = UIStackView() + + view.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = .defaultSpacing + + let describeLabel = UILabel() + + stackView.addArrangedSubview(describeLabel) + describeLabel.adjustsFontForContentSizeCategory = true + describeLabel.font = .preferredFont(forTextStyle: .headline) + describeLabel.numberOfLines = 0 + describeLabel.textAlignment = .center + + switch viewModel.attachment.type { + case .audio: + describeLabel.text = NSLocalizedString("attachment.edit.description.audio", comment: "") + case .video: + describeLabel.text = NSLocalizedString("attachment.edit.description.video", comment: "") + default: + describeLabel.text = NSLocalizedString("attachment.edit.description", comment: "") + } + + let textView = UITextView() + + stackView.addArrangedSubview(textView) + textView.adjustsFontForContentSizeCategory = true + textView.font = .preferredFont(forTextStyle: .body) + textView.layer.borderWidth = .hairline + textView.layer.borderColor = UIColor.separator.cgColor + textView.layer.cornerRadius = .defaultCornerRadius + textView.delegate = self + textView.text = viewModel.editingDescription + + let remainingCharactersLabel = UILabel() + + stackView.addArrangedSubview(remainingCharactersLabel) + remainingCharactersLabel.adjustsFontForContentSizeCategory = true + remainingCharactersLabel.font = .preferredFont(forTextStyle: .subheadline) + remainingCharactersLabel.text = "1500" + + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), + stackView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: .defaultSpacing), + editThumbnailView.leadingAnchor.constraint(equalTo: stackView.trailingAnchor, constant: .defaultSpacing), + stackView.bottomAnchor.constraint( + equalTo: view.layoutMarginsGuide.bottomAnchor, + constant: -.defaultSpacing), + editThumbnailView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + editThumbnailView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + editThumbnailView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + editThumbnailView.widthAnchor.constraint(equalTo: stackView.widthAnchor, multiplier: 3 / 2) + ]) + + viewModel.$descriptionRemainingCharacters + .sink { + remainingCharactersLabel.text = String($0) + remainingCharactersLabel.textColor = $0 < 0 ? .systemRed : .label + } + .store(in: &cancellables) + + textView.becomeFirstResponder() + } + + override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + + let cancelButton = UIBarButtonItem( + systemItem: .cancel, + primaryAction: UIAction { [weak self] _ in + self?.presentingViewController?.dismiss(animated: true) + }) + let doneButton = UIBarButtonItem( + systemItem: .done, + primaryAction: UIAction { [weak self] _ in + guard let self = self else { return } + + self.parentViewModel.update(attachmentViewModel: self.viewModel) + self.presentingViewController?.dismiss(animated: true) + }) + + parent?.navigationItem.leftBarButtonItem = cancelButton + parent?.navigationItem.rightBarButtonItem = doneButton + parent?.navigationItem.title = NSLocalizedString("attachment.edit.title", comment: "") + } +} + +extension EditAttachmentViewController: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + viewModel.editingDescription = textView.text + } +} diff --git a/View Controllers/NewStatusViewController.swift b/View Controllers/NewStatusViewController.swift index b968957..1172c5f 100644 --- a/View Controllers/NewStatusViewController.swift +++ b/View Controllers/NewStatusViewController.swift @@ -4,7 +4,7 @@ import AVFoundation import Combine import Kingfisher import PhotosUI -import UIKit +import SwiftUI import UniformTypeIdentifiers import ViewModels @@ -110,6 +110,10 @@ private extension NewStatusViewController { #if !IS_SHARE_EXTENSION presentCamera(compositionViewModel: compositionViewModel) #endif + case let .editAttachment(attachmentViewModel, compositionViewModel): + presentAttachmentEditor( + attachmentViewModel: attachmentViewModel, + compositionViewModel: compositionViewModel) } } @@ -276,6 +280,15 @@ private extension NewStatusViewController { } #endif + func presentAttachmentEditor(attachmentViewModel: AttachmentViewModel, compositionViewModel: CompositionViewModel) { + let editAttachmentsView = EditAttachmentView { (attachmentViewModel, compositionViewModel) } + let editAttachmentViewController = UIHostingController(rootView: editAttachmentsView) + let navigationController = UINavigationController(rootViewController: editAttachmentViewController) + + navigationController.modalPresentationStyle = .overFullScreen + present(navigationController, animated: true) + } + func changeIdentityButton(identification: Identification) -> UIButton { let changeIdentityButton = UIButton() let downsampled = KingfisherOptionsInfo.downsampled( diff --git a/ViewModels/Sources/ViewModels/AttachmentViewModel.swift b/ViewModels/Sources/ViewModels/AttachmentViewModel.swift index 7b959fd..436378d 100644 --- a/ViewModels/Sources/ViewModels/AttachmentViewModel.swift +++ b/ViewModels/Sources/ViewModels/AttachmentViewModel.swift @@ -1,11 +1,15 @@ // Copyright © 2020 Metabolist. All rights reserved. +import Combine import Foundation import Mastodon import Network public final class AttachmentViewModel: ObservableObject { public let attachment: Attachment + @Published public var editingDescription: String + @Published public var editingFocus: Attachment.Meta.Focus + @Published public private(set) var descriptionRemainingCharacters = AttachmentViewModel.descriptionMaxCharacters private let identification: Identification private let status: Status? @@ -14,6 +18,11 @@ public final class AttachmentViewModel: ObservableObject { self.attachment = attachment self.identification = identification self.status = status + editingDescription = attachment.description ?? "" + editingFocus = attachment.meta?.focus ?? .default + $editingDescription + .map { Self.descriptionMaxCharacters - $0.count } + .assign(to: &$descriptionRemainingCharacters) } } @@ -37,7 +46,21 @@ public extension AttachmentViewModel { } } +extension AttachmentViewModel { + func updated() -> AnyPublisher { + identification.service.updateAttachment(id: attachment.id, description: editingDescription, focus: editingFocus) + .compactMap { [weak self] in + guard let self = self else { return nil } + + return AttachmentViewModel(attachment: $0, identification: self.identification, status: self.status) + } + .eraseToAnyPublisher() + } +} + private extension AttachmentViewModel { + static let descriptionMaxCharacters = 1500 + static var wifiMonitor: NWPathMonitor = { let monitor = NWPathMonitor(requiredInterfaceType: .wifi) diff --git a/ViewModels/Sources/ViewModels/CompositionViewModel.swift b/ViewModels/Sources/ViewModels/CompositionViewModel.swift index 081ea79..75650c7 100644 --- a/ViewModels/Sources/ViewModels/CompositionViewModel.swift +++ b/ViewModels/Sources/ViewModels/CompositionViewModel.swift @@ -19,9 +19,12 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab @Published public private(set) var remainingCharacters = CompositionViewModel.maxCharacters public let canRemoveAttachments = true + private let eventsSubject: PassthroughSubject private var attachmentUploadCancellable: AnyCancellable? - init() { + init(eventsSubject: PassthroughSubject) { + self.eventsSubject = eventsSubject + $text.map { !$0.isEmpty } .removeDuplicates() .combineLatest($attachmentViewModels.map { !$0.isEmpty }) @@ -45,7 +48,7 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab } public func attachmentSelected(viewModel: AttachmentViewModel) { - + eventsSubject.send(.editAttachment(viewModel, self)) } public func removeAttachment(viewModel: AttachmentViewModel) { @@ -56,14 +59,13 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab public extension CompositionViewModel { static let maxCharacters = 500 - typealias Id = UUID - enum Event { - case insertAfter(CompositionViewModel) - case presentMediaPicker(CompositionViewModel) - case error(Error) + case editAttachment(AttachmentViewModel, CompositionViewModel) + case updateAttachment(AnyPublisher) } + typealias Id = UUID + func components(inReplyToId: Status.Id?, visibility: Status.Visibility) -> StatusComponents { StatusComponents( inReplyToId: inReplyToId, @@ -76,6 +78,23 @@ public extension CompositionViewModel { func cancelUpload() { attachmentUploadCancellable?.cancel() } + + func update(attachmentViewModel: AttachmentViewModel) { + let publisher = attachmentViewModel.updated() + .receive(on: DispatchQueue.main) + .handleEvents(receiveOutput: { [weak self] updatedAttachmentViewModel in + guard let self = self, + let index = self.attachmentViewModels.firstIndex( + where: { $0.attachment.id == updatedAttachmentViewModel.attachment.id }) + else { return } + + self.attachmentViewModels[index] = updatedAttachmentViewModel + }) + .ignoreOutput() + .eraseToAnyPublisher() + + eventsSubject.send(.updateAttachment(publisher)) + } } extension CompositionViewModel { diff --git a/ViewModels/Sources/ViewModels/NewStatusViewModel.swift b/ViewModels/Sources/ViewModels/NewStatusViewModel.swift index 7a02411..43097fa 100644 --- a/ViewModels/Sources/ViewModels/NewStatusViewModel.swift +++ b/ViewModels/Sources/ViewModels/NewStatusViewModel.swift @@ -7,7 +7,7 @@ import ServiceLayer public final class NewStatusViewModel: ObservableObject { @Published public var visibility: Status.Visibility - @Published public private(set) var compositionViewModels = [CompositionViewModel()] + @Published public private(set) var compositionViewModels: [CompositionViewModel] @Published public private(set) var identification: Identification @Published public private(set) var authenticatedIdentities = [Identity]() @Published public var canPost = false @@ -19,7 +19,7 @@ public final class NewStatusViewModel: ObservableObject { private let allIdentitiesService: AllIdentitiesService private let environment: AppEnvironment private let eventsSubject = PassthroughSubject() - private let itemEventsSubject = PassthroughSubject() + private let compositionEventsSubject = PassthroughSubject() private var cancellables = Set() public init(allIdentitiesService: AllIdentitiesService, @@ -28,6 +28,7 @@ public final class NewStatusViewModel: ObservableObject { self.allIdentitiesService = allIdentitiesService self.identification = identification self.environment = environment + compositionViewModels = [CompositionViewModel(eventsSubject: compositionEventsSubject)] events = eventsSubject.eraseToAnyPublisher() visibility = identification.identity.preferences.postingDefaultVisibility allIdentitiesService.authenticatedIdentitiesPublisher() @@ -39,6 +40,9 @@ public final class NewStatusViewModel: ObservableObject { .combineLatest($postingState) .map { $0 && $1 == .composing } .assign(to: &$canPost) + compositionEventsSubject + .sink { [weak self] in self?.handle(event: $0) } + .store(in: &cancellables) } } @@ -46,6 +50,7 @@ public extension NewStatusViewModel { enum Event { case presentMediaPicker(CompositionViewModel) case presentCamera(CompositionViewModel) + case editAttachment(AttachmentViewModel, CompositionViewModel) } enum PostingState { @@ -85,7 +90,7 @@ public extension NewStatusViewModel { guard let index = compositionViewModels.firstIndex(where: { $0 === after }) else { return } - let newViewModel = CompositionViewModel() + let newViewModel = CompositionViewModel(eventsSubject: compositionEventsSubject) newViewModel.contentWarning = after.contentWarning newViewModel.displayContentWarning = after.displayContentWarning @@ -109,6 +114,14 @@ public extension NewStatusViewModel { } private extension NewStatusViewModel { + func handle(event: CompositionViewModel.Event) { + switch event { + case let .editAttachment(attachmentViewModel, compositionViewModel): + eventsSubject.send(.editAttachment(attachmentViewModel, compositionViewModel)) + case let .updateAttachment(publisher): + publisher.assignErrorsToAlertItem(to: \.alertItem, on: self).sink { _ in }.store(in: &cancellables) + } + } func post(viewModel: CompositionViewModel, inReplyToId: Status.Id?) { postingState = .posting identification.service.post(statusComponents: viewModel.components( diff --git a/Views/AttachmentView.swift b/Views/AttachmentView.swift index da43827..7e6a0bc 100644 --- a/Views/AttachmentView.swift +++ b/Views/AttachmentView.swift @@ -47,29 +47,13 @@ final class AttachmentView: UIView { super.layoutSubviews() if let focus = viewModel.attachment.meta?.focus { - let viewsAndSizes: [(UIView, CGSize?)] = [ + let viewsAndMediaSizes: [(UIView, CGSize?)] = [ (imageView, imageView.image?.size), (playerView, playerView.player?.currentItem?.presentationSize)] - for (view, size) in viewsAndSizes { - guard let size = size else { continue } + for (view, mediaSize) in viewsAndMediaSizes { + guard let size = mediaSize else { continue } - let aspectRatio = size.width / size.height - let viewAspectRatio = view.frame.width / view.frame.height - var origin = CGPoint.zero - - if viewAspectRatio > aspectRatio { - let mediaProportionalHeight = size.height * view.frame.width / size.width - let maxPan = (mediaProportionalHeight - view.frame.height) / (2 * mediaProportionalHeight) - - origin.y = CGFloat(-focus.y) * maxPan - } else { - let mediaProportionalWidth = size.width * view.frame.height / size.height - let maxPan = (mediaProportionalWidth - view.frame.width) / (2 * mediaProportionalWidth) - - origin.x = CGFloat(focus.x) * maxPan - } - - view.layer.contentsRect = .init(origin: origin, size: CGRect.defaultContentsRect.size) + view.setContentsRect(focus: focus, mediaSize: size) } } } diff --git a/Views/EditAttachmentView.swift b/Views/EditAttachmentView.swift new file mode 100644 index 0000000..837ada7 --- /dev/null +++ b/Views/EditAttachmentView.swift @@ -0,0 +1,18 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import SwiftUI +import ViewModels + +struct EditAttachmentView: UIViewControllerRepresentable { + let viewModelsClosure: () -> (AttachmentViewModel, CompositionViewModel) + + func makeUIViewController(context: Context) -> EditAttachmentViewController { + let (attachmentViewModel, compositionViewModel) = viewModelsClosure() + + return EditAttachmentViewController(viewModel: attachmentViewModel, parentViewModel: compositionViewModel) + } + + func updateUIViewController(_ uiViewController: EditAttachmentViewController, context: Context) { + + } +} diff --git a/Views/EditThumbnailView.swift b/Views/EditThumbnailView.swift new file mode 100644 index 0000000..3f135b5 --- /dev/null +++ b/Views/EditThumbnailView.swift @@ -0,0 +1,218 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Combine +import Kingfisher +import UIKit +import ViewModels + +final class EditThumbnailView: UIView { + let playerView = PlayerView() + let imageView = UIImageView() + let previewImageView = UIImageView() + let promptBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) + let thumbnailPromptLabel = UILabel() + + private let viewModel: AttachmentViewModel + private var cancellables = Set() + + private lazy var circleView: UIVisualEffectView = { + let blurEffect = UIBlurEffect(style: .systemUltraThinMaterial) + let circleView = UIVisualEffectView(effect: blurEffect) + let vibrancyView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: blurEffect)) + let scopeImageView = UIImageView( + image: UIImage(systemName: "scope", + withConfiguration: UIImage.SymbolConfiguration(scale: .medium))) + + circleView.translatesAutoresizingMaskIntoConstraints = false + vibrancyView.translatesAutoresizingMaskIntoConstraints = false + scopeImageView.translatesAutoresizingMaskIntoConstraints = false + vibrancyView.contentView.addSubview(scopeImageView) + circleView.contentView.addSubview(vibrancyView) + circleView.layer.cornerRadius = .minimumButtonDimension / 2 + circleView.clipsToBounds = true + scopeImageView.contentMode = .scaleAspectFit + + NSLayoutConstraint.activate([ + scopeImageView.centerXAnchor.constraint(equalTo: circleView.contentView.centerXAnchor), + scopeImageView.centerYAnchor.constraint(equalTo: circleView.contentView.centerYAnchor), + vibrancyView.leadingAnchor.constraint(equalTo: circleView.leadingAnchor), + vibrancyView.topAnchor.constraint(equalTo: circleView.topAnchor), + vibrancyView.trailingAnchor.constraint(equalTo: circleView.trailingAnchor), + vibrancyView.bottomAnchor.constraint(equalTo: circleView.bottomAnchor), + circleView.trailingAnchor.constraint( + equalTo: scopeImageView.trailingAnchor, constant: .compactSpacing), + circleView.bottomAnchor.constraint( + equalTo: scopeImageView.bottomAnchor, constant: .compactSpacing), + scopeImageView.topAnchor.constraint( + equalTo: circleView.topAnchor, constant: .compactSpacing), + scopeImageView.leadingAnchor.constraint( + equalTo: circleView.leadingAnchor, constant: .compactSpacing), + circleView.widthAnchor.constraint(equalToConstant: .minimumButtonDimension), + circleView.heightAnchor.constraint(equalToConstant: .minimumButtonDimension) + ]) + + return circleView + }() + + init(viewModel: AttachmentViewModel) { + self.viewModel = viewModel + + super.init(frame: .zero) + + initialSetup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + super.touchesMoved(touches, with: event) + + guard let touch = touches.first else { return } + + if promptBackgroundView.effect != nil { + UIView.animate(withDuration: .defaultAnimationDuration) { + self.promptBackgroundView.effect = nil + self.thumbnailPromptLabel.alpha = 0 + } + } + + let location = touch.location(in: self) + + viewModel.editingFocus.x = Double(max(min(((location.x - (bounds.width / 2)) / (bounds.width / 2)), 1), -1)) + viewModel.editingFocus.y = Double(max(min((-location.y / (bounds.height / 2)) + 1, 1), -1)) + } +} + +private extension EditThumbnailView { + // swiftlint:disable:next function_body_length + func initialSetup() { + backgroundColor = .secondarySystemBackground + + addSubview(imageView) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + imageView.kf.indicatorType = .activity + + addSubview(playerView) + playerView.translatesAutoresizingMaskIntoConstraints = false + + addSubview(circleView) + + let circleViewCenterXConstraint = circleView.centerXAnchor.constraint(equalTo: centerXAnchor) + let circleViewCenterYConstraint = circleView.centerYAnchor.constraint(equalTo: centerYAnchor) + + addSubview(promptBackgroundView) + promptBackgroundView.translatesAutoresizingMaskIntoConstraints = false + + if viewModel.editingFocus != .default { + promptBackgroundView.effect = nil + } + + promptBackgroundView.contentView.addSubview(thumbnailPromptLabel) + thumbnailPromptLabel.translatesAutoresizingMaskIntoConstraints = false + thumbnailPromptLabel.adjustsFontForContentSizeCategory = true + thumbnailPromptLabel.font = .preferredFont(forTextStyle: .caption1) + thumbnailPromptLabel.numberOfLines = 0 + thumbnailPromptLabel.textAlignment = .center + thumbnailPromptLabel.text = NSLocalizedString("attachment.edit.thumbnail.prompt", comment: "") + + if viewModel.editingFocus != .default { + thumbnailPromptLabel.alpha = 0 + } + + let previewImageContainerView = UIView() + + addSubview(previewImageContainerView) + previewImageContainerView.translatesAutoresizingMaskIntoConstraints = false + previewImageContainerView.layer.cornerRadius = .defaultCornerRadius + previewImageContainerView.layer.shadowOffset = .zero + previewImageContainerView.layer.shadowRadius = .defaultShadowRadius + previewImageContainerView.layer.shadowOpacity = 0.25 + + previewImageContainerView.addSubview(previewImageView) + previewImageView.translatesAutoresizingMaskIntoConstraints = false + previewImageView.contentMode = .scaleAspectFill + previewImageView.clipsToBounds = true + previewImageView.layer.cornerRadius = .defaultCornerRadius + previewImageView.kf.setImage(with: viewModel.attachment.previewUrl) + + switch viewModel.attachment.type { + case .image: + playerView.isHidden = true + imageView.kf.setImage( + with: viewModel.attachment.previewUrl, + options: [.onlyFromCache], + completionHandler: { [weak self] in + guard let self = self else { return } + + if case .success = $0 { + self.imageView.kf.indicatorType = .none + } + + self.imageView.kf.setImage( + with: self.viewModel.attachment.url, + options: [.keepCurrentImageWhileLoading]) + }) + case .gifv: + imageView.isHidden = true + let player = PlayerCache.shared.player(url: viewModel.attachment.url) + + player.isMuted = true + + playerView.player = player + player.play() + default: break + } + + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor), + playerView.leadingAnchor.constraint(equalTo: leadingAnchor), + playerView.trailingAnchor.constraint(equalTo: trailingAnchor), + playerView.topAnchor.constraint(equalTo: topAnchor), + playerView.bottomAnchor.constraint(equalTo: bottomAnchor), + circleViewCenterXConstraint, + circleViewCenterYConstraint, + promptBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + promptBackgroundView.topAnchor.constraint(equalTo: topAnchor), + promptBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + thumbnailPromptLabel.leadingAnchor.constraint( + equalTo: promptBackgroundView.layoutMarginsGuide.leadingAnchor), + thumbnailPromptLabel.topAnchor.constraint(equalTo: promptBackgroundView.layoutMarginsGuide.topAnchor), + thumbnailPromptLabel.trailingAnchor.constraint( + equalTo: promptBackgroundView.layoutMarginsGuide.trailingAnchor), + thumbnailPromptLabel.bottomAnchor.constraint(equalTo: promptBackgroundView.layoutMarginsGuide.bottomAnchor), + previewImageView.leadingAnchor.constraint(equalTo: previewImageContainerView.leadingAnchor), + previewImageView.topAnchor.constraint(equalTo: previewImageContainerView.topAnchor), + previewImageView.trailingAnchor.constraint(equalTo: previewImageContainerView.trailingAnchor), + previewImageView.bottomAnchor.constraint(equalTo: previewImageContainerView.bottomAnchor), + previewImageContainerView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + previewImageContainerView.bottomAnchor.constraint( + equalTo: layoutMarginsGuide.bottomAnchor, + constant: -.defaultSpacing), + previewImageContainerView.widthAnchor.constraint( + equalTo: previewImageContainerView.heightAnchor, + multiplier: 16 / 9), + previewImageContainerView.heightAnchor.constraint(equalTo: heightAnchor, multiplier: 1 / 8) + ]) + + viewModel.$editingFocus + .receive(on: DispatchQueue.main) // punt to next run loop to allow initial layout to happen + .sink { [weak self] in + guard let self = self else { return } + + circleViewCenterXConstraint.constant = CGFloat($0.x) * self.bounds.width / 2 + circleViewCenterYConstraint.constant = -CGFloat($0.y) * self.bounds.height / 2 + + guard let mediaSize = self.previewImageView.image?.size else { return } + + self.previewImageView.setContentsRect(focus: $0, mediaSize: mediaSize) + } + .store(in: &cancellables) + } +} diff --git a/Views/TabNavigationView.swift b/Views/TabNavigationView.swift index 09a8bc4..28bc2ef 100644 --- a/Views/TabNavigationView.swift +++ b/Views/TabNavigationView.swift @@ -181,7 +181,7 @@ private extension TabNavigationView { .clipShape(Circle()) .frame(width: .newStatusButtonDimension, height: .newStatusButtonDimension) - .shadow(radius: .newStatusButtonShadowRadius) + .shadow(radius: .defaultShadowRadius) .padding() } } diff --git a/Views/ViewConstants.swift b/Views/ViewConstants.swift index 397f503..d6306a3 100644 --- a/Views/ViewConstants.swift +++ b/Views/ViewConstants.swift @@ -12,7 +12,7 @@ extension CGFloat { static let minimumButtonDimension: Self = 44 static let barButtonItemDimension: Self = 28 static let newStatusButtonDimension: CGFloat = 54 - static let newStatusButtonShadowRadius: CGFloat = 2 + static let defaultShadowRadius: CGFloat = 2 } extension CGRect {