Edit attachments

This commit is contained in:
Justin Mazzocchi 2021-01-09 17:26:51 -08:00
parent 11f43c3df5
commit 032e187681
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
16 changed files with 534 additions and 35 deletions

View file

@ -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)
}
}

View file

@ -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";

View file

@ -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)
}

View file

@ -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
}
}
}

View file

@ -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 = "<group>"; };
D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupAndSyncingPreferencesView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
D05936DD25A937EC00754FDF /* EditThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditThumbnailView.swift; sourceTree = "<group>"; };
D05936E825AA3F3D00754FDF /* EditAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAttachmentView.swift; sourceTree = "<group>"; };
D05936F325AA66A600754FDF /* UIView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Extensions.swift"; sourceTree = "<group>"; };
D0625E58250F092900502611 /* StatusListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusListCell.swift; sourceTree = "<group>"; };
D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentConfiguration.swift; sourceTree = "<group>"; };
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;
};

View file

@ -212,6 +212,12 @@ public extension IdentityService {
progress: progress)
}
func updateAttachment(id: Attachment.Id,
description: String,
focus: Attachment.Meta.Focus) -> AnyPublisher<Attachment, Error> {
mastodonAPIClient.request(AttachmentEndpoint.update(id: id, description: description, focus: focus))
}
func post(statusComponents: StatusComponents) -> AnyPublisher<Status.Id, Error> {
mastodonAPIClient.request(StatusEndpoint.post(statusComponents)).map(\.id).eraseToAnyPublisher()
}

View file

@ -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<AnyCancellable>()
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
}
}

View file

@ -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(

View file

@ -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<AttachmentViewModel, Error> {
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)

View file

@ -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<Event, Never>
private var attachmentUploadCancellable: AnyCancellable?
init() {
init(eventsSubject: PassthroughSubject<Event, Never>) {
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<Never, Error>)
}
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 {

View file

@ -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<Event, Never>()
private let itemEventsSubject = PassthroughSubject<CompositionViewModel.Event, Never>()
private let compositionEventsSubject = PassthroughSubject<CompositionViewModel.Event, Never>()
private var cancellables = Set<AnyCancellable>()
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(

View file

@ -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)
}
}
}

View file

@ -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) {
}
}

View file

@ -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<AnyCancellable>()
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<UITouch>, 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)
}
}

View file

@ -181,7 +181,7 @@ private extension TabNavigationView {
.clipShape(Circle())
.frame(width: .newStatusButtonDimension,
height: .newStatusButtonDimension)
.shadow(radius: .newStatusButtonShadowRadius)
.shadow(radius: .defaultShadowRadius)
.padding()
}
}

View file

@ -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 {