302 lines
15 KiB
Swift
302 lines
15 KiB
Swift
//
|
|
// ComposeStatusSection.swift
|
|
// Mastodon
|
|
//
|
|
// Created by MainasuK Cirno on 2021-3-11.
|
|
//
|
|
|
|
import UIKit
|
|
import Combine
|
|
import CoreData
|
|
import CoreDataStack
|
|
import TwitterTextEditor
|
|
import AlamofireImage
|
|
|
|
enum ComposeStatusSection: Equatable, Hashable {
|
|
case repliedTo
|
|
case status
|
|
case attachment
|
|
case poll
|
|
}
|
|
|
|
extension ComposeStatusSection {
|
|
enum ComposeKind {
|
|
case post
|
|
case hashtag(hashtag: String)
|
|
case mention(mastodonUserObjectID: NSManagedObjectID)
|
|
case reply(repliedToStatusObjectID: NSManagedObjectID)
|
|
}
|
|
}
|
|
|
|
extension ComposeStatusSection {
|
|
static func collectionViewDiffableDataSource(
|
|
for collectionView: UICollectionView,
|
|
dependency: NeedsDependency,
|
|
managedObjectContext: NSManagedObjectContext,
|
|
composeKind: ComposeKind,
|
|
customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel,
|
|
textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate,
|
|
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
|
|
composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
|
|
composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate,
|
|
composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate
|
|
) -> UICollectionViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem> {
|
|
UICollectionViewDiffableDataSource(collectionView: collectionView) { [
|
|
weak customEmojiPickerInputViewModel,
|
|
weak textEditorViewTextAttributesDelegate,
|
|
weak composeStatusAttachmentTableViewCellDelegate,
|
|
weak composeStatusPollOptionCollectionViewCellDelegate,
|
|
weak composeStatusNewPollOptionCollectionViewCellDelegate,
|
|
weak composeStatusPollExpiresOptionCollectionViewCellDelegate
|
|
] collectionView, indexPath, item -> UICollectionViewCell? in
|
|
switch item {
|
|
case .replyTo(let replyToStatusObjectID):
|
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentCollectionViewCell
|
|
managedObjectContext.perform {
|
|
guard let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
|
|
return
|
|
}
|
|
let status = replyTo.reblog ?? replyTo
|
|
|
|
// set avatar
|
|
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL()))
|
|
// set name username
|
|
cell.statusView.nameLabel.text = {
|
|
let author = status.author
|
|
return author.displayName.isEmpty ? author.username : author.displayName
|
|
}()
|
|
cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct
|
|
// set text
|
|
cell.statusView.activeTextLabel.configure(content: status.content)
|
|
// set date
|
|
cell.statusView.dateLabel.text = status.createdAt.shortTimeAgoSinceNow
|
|
}
|
|
return cell
|
|
case .input(let replyToStatusObjectID, let attribute):
|
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell
|
|
cell.textEditorView.text = attribute.composeContent.value ?? ""
|
|
managedObjectContext.perform {
|
|
guard let replyToStatusObjectID = replyToStatusObjectID,
|
|
let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
|
|
cell.statusView.headerContainerStackView.isHidden = true
|
|
return
|
|
}
|
|
cell.statusView.headerContainerStackView.isHidden = false
|
|
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage)
|
|
cell.statusView.headerInfoLabel.text = L10n.Scene.Compose.replyingToUser(replyTo.author.displayNameWithFallback)
|
|
}
|
|
ComposeStatusSection.configureStatusContent(cell: cell, attribute: attribute)
|
|
cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate
|
|
cell.composeContent
|
|
.removeDuplicates()
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { text in
|
|
// self size input cell
|
|
collectionView.collectionViewLayout.invalidateLayout()
|
|
// bind input data
|
|
attribute.composeContent.value = text
|
|
}
|
|
.store(in: &cell.disposeBag)
|
|
attribute.isContentWarningComposing
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { isContentWarningComposing in
|
|
// self size input cell
|
|
collectionView.collectionViewLayout.invalidateLayout()
|
|
cell.statusContentWarningEditorView.containerView.isHidden = !isContentWarningComposing
|
|
cell.statusContentWarningEditorView.alpha = 0
|
|
UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) {
|
|
cell.statusContentWarningEditorView.alpha = 1
|
|
} completion: { _ in
|
|
// do nothing
|
|
}
|
|
}
|
|
.store(in: &cell.disposeBag)
|
|
cell.contentWarningContent
|
|
.removeDuplicates()
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { text in
|
|
// self size input cell
|
|
collectionView.collectionViewLayout.invalidateLayout()
|
|
// bind input data
|
|
attribute.contentWarningContent.value = text
|
|
}
|
|
.store(in: &cell.disposeBag)
|
|
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag)
|
|
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag)
|
|
|
|
return cell
|
|
case .attachment(let attachmentService):
|
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell
|
|
cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value
|
|
cell.delegate = composeStatusAttachmentTableViewCellDelegate
|
|
attachmentService.imageData
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { imageData in
|
|
let size = cell.attachmentContainerView.previewImageView.frame.size != .zero ? cell.attachmentContainerView.previewImageView.frame.size : CGSize(width: 1, height: 1)
|
|
guard let imageData = imageData,
|
|
let image = UIImage(data: imageData) else {
|
|
let placeholder = UIImage.placeholder(
|
|
size: size,
|
|
color: Asset.Colors.Background.systemGroupedBackground.color
|
|
)
|
|
.af.imageRounded(
|
|
withCornerRadius: AttachmentContainerView.containerViewCornerRadius
|
|
)
|
|
cell.attachmentContainerView.previewImageView.image = placeholder
|
|
return
|
|
}
|
|
cell.attachmentContainerView.previewImageView.image = image
|
|
.af.imageAspectScaled(toFill: size)
|
|
.af.imageRounded(withCornerRadius: AttachmentContainerView.containerViewCornerRadius)
|
|
}
|
|
.store(in: &cell.disposeBag)
|
|
Publishers.CombineLatest(
|
|
attachmentService.uploadStateMachineSubject.eraseToAnyPublisher(),
|
|
attachmentService.error.eraseToAnyPublisher()
|
|
)
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { uploadState, error in
|
|
cell.attachmentContainerView.emptyStateView.isHidden = error == nil
|
|
cell.attachmentContainerView.descriptionBackgroundView.isHidden = error != nil
|
|
if let _ = error {
|
|
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
|
|
} else {
|
|
guard let uploadState = uploadState else { return }
|
|
switch uploadState {
|
|
case is MastodonAttachmentService.UploadState.Finish,
|
|
is MastodonAttachmentService.UploadState.Fail:
|
|
cell.attachmentContainerView.activityIndicatorView.stopAnimating()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
.store(in: &cell.disposeBag)
|
|
NotificationCenter.default.publisher(
|
|
for: UITextView.textDidChangeNotification,
|
|
object: cell.attachmentContainerView.descriptionTextView
|
|
)
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { notification in
|
|
guard let textField = notification.object as? UITextView else { return }
|
|
let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
attachmentService.description.value = text
|
|
}
|
|
.store(in: &cell.disposeBag)
|
|
return cell
|
|
case .pollOption(let attribute):
|
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionCollectionViewCell
|
|
cell.pollOptionView.optionTextField.text = attribute.option.value
|
|
cell.pollOption
|
|
.receive(on: DispatchQueue.main)
|
|
.assign(to: \.value, on: attribute.option)
|
|
.store(in: &cell.disposeBag)
|
|
cell.delegate = composeStatusPollOptionCollectionViewCellDelegate
|
|
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.pollOptionView.optionTextField, disposeBag: &cell.disposeBag)
|
|
return cell
|
|
case .pollOptionAppendEntry:
|
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionAppendEntryCollectionViewCell
|
|
cell.delegate = composeStatusNewPollOptionCollectionViewCellDelegate
|
|
return cell
|
|
case .pollExpiresOption(let attribute):
|
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollExpiresOptionCollectionViewCell
|
|
cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(attribute.expiresOption.value.title), for: .normal)
|
|
attribute.expiresOption
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { expiresOption in
|
|
cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(expiresOption.title), for: .normal)
|
|
}
|
|
.store(in: &cell.disposeBag)
|
|
cell.delegate = composeStatusPollExpiresOptionCollectionViewCellDelegate
|
|
return cell
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ComposeStatusSection {
|
|
|
|
static func configureStatusContent(
|
|
cell: ComposeStatusContentCollectionViewCell,
|
|
attribute: ComposeStatusItem.ComposeStatusAttribute
|
|
) {
|
|
// set avatar
|
|
attribute.avatarURL
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { avatarURL in
|
|
cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarURL))
|
|
}
|
|
.store(in: &cell.disposeBag)
|
|
// set display name and username
|
|
Publishers.CombineLatest(
|
|
attribute.displayName.eraseToAnyPublisher(),
|
|
attribute.username.eraseToAnyPublisher()
|
|
)
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { displayName, username in
|
|
cell.statusView.nameLabel.text = displayName
|
|
cell.statusView.usernameLabel.text = username.flatMap { "@" + $0 } ?? " "
|
|
}
|
|
.store(in: &cell.disposeBag)
|
|
|
|
// bind compose content
|
|
cell.composeContent
|
|
.map { $0 as String? }
|
|
.assign(to: \.value, on: attribute.composeContent)
|
|
.store(in: &cell.disposeBag)
|
|
}
|
|
|
|
}
|
|
|
|
protocol CustomEmojiReplacableTextInput: AnyObject {
|
|
var inputView: UIView? { get set }
|
|
func reloadInputViews()
|
|
|
|
// UIKeyInput
|
|
func insertText(_ text: String)
|
|
// UIResponder
|
|
var isFirstResponder: Bool { get }
|
|
}
|
|
|
|
class CustomEmojiReplacableTextInputReference {
|
|
weak var value: CustomEmojiReplacableTextInput?
|
|
|
|
init(value: CustomEmojiReplacableTextInput? = nil) {
|
|
self.value = value
|
|
}
|
|
}
|
|
|
|
extension TextEditorView: CustomEmojiReplacableTextInput {
|
|
func insertText(_ text: String) {
|
|
try? updateByReplacing(range: selectedRange, with: text, selectedRange: nil)
|
|
}
|
|
|
|
public override var isFirstResponder: Bool {
|
|
return isEditing
|
|
}
|
|
|
|
}
|
|
extension UITextField: CustomEmojiReplacableTextInput { }
|
|
extension UITextView: CustomEmojiReplacableTextInput { }
|
|
|
|
extension ComposeStatusSection {
|
|
|
|
static func configureCustomEmojiPicker(
|
|
viewModel: CustomEmojiPickerInputViewModel?,
|
|
customEmojiReplacableTextInput: CustomEmojiReplacableTextInput,
|
|
disposeBag: inout Set<AnyCancellable>
|
|
) {
|
|
guard let viewModel = viewModel else { return }
|
|
viewModel.isCustomEmojiComposing
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak viewModel] isCustomEmojiComposing in
|
|
guard let viewModel = viewModel else { return }
|
|
customEmojiReplacableTextInput.inputView = isCustomEmojiComposing ? viewModel.customEmojiPickerInputView : nil
|
|
customEmojiReplacableTextInput.reloadInputViews()
|
|
viewModel.append(customEmojiReplacableTextInput: customEmojiReplacableTextInput)
|
|
}
|
|
.store(in: &disposeBag)
|
|
}
|
|
|
|
}
|