chore: [WIP] migrate compose scene from collection view to table view. Add MetaTextView
This commit is contained in:
parent
5f7d9497cc
commit
2d374f5908
|
@ -188,6 +188,11 @@
|
|||
DB029E95266A20430062874E /* MastodonAuthenticationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB029E94266A20430062874E /* MastodonAuthenticationController.swift */; };
|
||||
DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; };
|
||||
DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; };
|
||||
DB03F7EB268976B5007B274C /* MastodonMeta in Frameworks */ = {isa = PBXBuildFile; productRef = DB03F7EA268976B5007B274C /* MastodonMeta */; };
|
||||
DB03F7ED268976B5007B274C /* MetaTextView in Frameworks */ = {isa = PBXBuildFile; productRef = DB03F7EC268976B5007B274C /* MetaTextView */; };
|
||||
DB03F7F026899097007B274C /* ComposeStatusContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB03F7EF26899097007B274C /* ComposeStatusContentTableViewCell.swift */; };
|
||||
DB03F7F32689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */; };
|
||||
DB03F7F52689B782007B274C /* ComposeTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB03F7F42689B782007B274C /* ComposeTableView.swift */; };
|
||||
DB040ECD26526EA600BEE9D8 /* ComposeCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */; };
|
||||
DB040ED126538E3D00BEE9D8 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB040ED026538E3C00BEE9D8 /* Trie.swift */; };
|
||||
DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; };
|
||||
|
@ -799,6 +804,9 @@
|
|||
DB029E94266A20430062874E /* MastodonAuthenticationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAuthenticationController.swift; sourceTree = "<group>"; };
|
||||
DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = "<group>"; };
|
||||
DB03F7EF26899097007B274C /* ComposeStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentTableViewCell.swift; sourceTree = "<group>"; };
|
||||
DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentTableViewCell.swift; sourceTree = "<group>"; };
|
||||
DB03F7F42689B782007B274C /* ComposeTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTableView.swift; sourceTree = "<group>"; };
|
||||
DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCollectionView.swift; sourceTree = "<group>"; };
|
||||
DB040ED026538E3C00BEE9D8 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = "<group>"; };
|
||||
DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = "<group>"; };
|
||||
|
@ -1120,6 +1128,7 @@
|
|||
DBAEDE5F267A0B1500D25FF5 /* Nuke in Frameworks */,
|
||||
DB6F5E32264E7410009108F4 /* TwitterTextEditor in Frameworks */,
|
||||
DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */,
|
||||
DB03F7ED268976B5007B274C /* MetaTextView in Frameworks */,
|
||||
DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */,
|
||||
2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */,
|
||||
DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */,
|
||||
|
@ -1138,6 +1147,7 @@
|
|||
2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */,
|
||||
87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */,
|
||||
DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */,
|
||||
DB03F7EB268976B5007B274C /* MastodonMeta in Frameworks */,
|
||||
DBF7A0FC26830C33004176A2 /* FPSIndicator in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -1732,6 +1742,15 @@
|
|||
path = Status;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB03F7F1268990A2007B274C /* TableViewCell */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB03F7F22689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift */,
|
||||
DB03F7EF26899097007B274C /* ComposeStatusContentTableViewCell.swift */,
|
||||
);
|
||||
path = TableViewCell;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB084B5125CBC56300F898ED /* CoreDataStack */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1940,6 +1959,7 @@
|
|||
DB55D32225FB4D320002F825 /* View */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB03F7F42689B782007B274C /* ComposeTableView.swift */,
|
||||
DB040ECC26526EA600BEE9D8 /* ComposeCollectionView.swift */,
|
||||
DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */,
|
||||
DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */,
|
||||
|
@ -2082,6 +2102,7 @@
|
|||
DB6F5E36264E78EA009108F4 /* AutoComplete */,
|
||||
DB55D32225FB4D320002F825 /* View */,
|
||||
DB789A2125F9F76D0071ACA0 /* CollectionViewCell */,
|
||||
DB03F7F1268990A2007B274C /* TableViewCell */,
|
||||
DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */,
|
||||
DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */,
|
||||
DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */,
|
||||
|
@ -2653,6 +2674,8 @@
|
|||
DBAC64A0267E6D02007FE9FD /* Fuzi */,
|
||||
DBF7A0FB26830C33004176A2 /* FPSIndicator */,
|
||||
DB0E2D2D26833FF700865C3C /* NukeFLAnimatedImagePlugin */,
|
||||
DB03F7EA268976B5007B274C /* MastodonMeta */,
|
||||
DB03F7EC268976B5007B274C /* MetaTextView */,
|
||||
);
|
||||
productName = Mastodon;
|
||||
productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */;
|
||||
|
@ -2846,6 +2869,7 @@
|
|||
DBAC649F267E6D01007FE9FD /* XCRemoteSwiftPackageReference "Fuzi" */,
|
||||
DBF7A0FA26830C33004176A2 /* XCRemoteSwiftPackageReference "FPSIndicator" */,
|
||||
DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */,
|
||||
DB03F7E9268976B5007B274C /* XCRemoteSwiftPackageReference "MetaTextView" */,
|
||||
);
|
||||
productRefGroup = DB427DD325BAA00100D1B89D /* Products */;
|
||||
projectDirPath = "";
|
||||
|
@ -3218,6 +3242,7 @@
|
|||
DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */,
|
||||
2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */,
|
||||
DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */,
|
||||
DB03F7F32689AEA3007B274C /* ComposeRepliedToStatusContentTableViewCell.swift in Sources */,
|
||||
2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */,
|
||||
DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */,
|
||||
DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */,
|
||||
|
@ -3325,6 +3350,7 @@
|
|||
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
|
||||
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */,
|
||||
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
|
||||
DB03F7F52689B782007B274C /* ComposeTableView.swift in Sources */,
|
||||
2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */,
|
||||
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
|
||||
DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */,
|
||||
|
@ -3471,6 +3497,7 @@
|
|||
DBBF1DC226524D2900E5B703 /* AutoCompleteTableViewCell.swift in Sources */,
|
||||
2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */,
|
||||
DB1EE7B2267F9525000CC337 /* StatusProvider+StatusNodeDelegate.swift in Sources */,
|
||||
DB03F7F026899097007B274C /* ComposeStatusContentTableViewCell.swift in Sources */,
|
||||
5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */,
|
||||
5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */,
|
||||
DBA94436265CBB7400C537E1 /* ProfileFieldItem.swift in Sources */,
|
||||
|
@ -4730,6 +4757,14 @@
|
|||
minimumVersion = 0.1.1;
|
||||
};
|
||||
};
|
||||
DB03F7E9268976B5007B274C /* XCRemoteSwiftPackageReference "MetaTextView" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/TwidereProject/MetaTextView.git";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 1.2.0;
|
||||
};
|
||||
};
|
||||
DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/kean/Nuke-FLAnimatedImage-Plugin.git";
|
||||
|
@ -4863,6 +4898,16 @@
|
|||
package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */;
|
||||
productName = CommonOSLog;
|
||||
};
|
||||
DB03F7EA268976B5007B274C /* MastodonMeta */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = DB03F7E9268976B5007B274C /* XCRemoteSwiftPackageReference "MetaTextView" */;
|
||||
productName = MastodonMeta;
|
||||
};
|
||||
DB03F7EC268976B5007B274C /* MetaTextView */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = DB03F7E9268976B5007B274C /* XCRemoteSwiftPackageReference "MetaTextView" */;
|
||||
productName = MetaTextView;
|
||||
};
|
||||
DB0E2D2D26833FF700865C3C /* NukeFLAnimatedImagePlugin */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = DB0E2D2C26833FF600865C3C /* XCRemoteSwiftPackageReference "Nuke-FLAnimatedImage-Plugin" */;
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<key>CoreDataStack.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>20</integer>
|
||||
<integer>21</integer>
|
||||
</dict>
|
||||
<key>Mastodon - ASDK.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -37,7 +37,7 @@
|
|||
<key>NotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>21</integer>
|
||||
<integer>22</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
|
|
@ -109,6 +109,15 @@
|
|||
"version": "6.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "MetaTextView",
|
||||
"repositoryURL": "https://github.com/TwidereProject/MetaTextView.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "637b73044e665e8b9678ed64dd2a83314c286aef",
|
||||
"version": "1.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "Nuke",
|
||||
"repositoryURL": "https://github.com/kean/Nuke.git",
|
||||
|
|
|
@ -9,7 +9,8 @@ import UIKit
|
|||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import TwitterTextEditor
|
||||
import MetaTextView
|
||||
import MastodonMeta
|
||||
import AlamofireImage
|
||||
|
||||
enum ComposeStatusSection: Equatable, Hashable {
|
||||
|
@ -36,8 +37,8 @@ extension ComposeStatusSection {
|
|||
composeKind: ComposeKind,
|
||||
repliedToCellFrameSubscriber: CurrentValueSubject<CGRect, Never>,
|
||||
customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel,
|
||||
textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate,
|
||||
textEditorViewChangeObserver: TextEditorViewChangeObserver,
|
||||
metaTextDelegate: MetaTextDelegate,
|
||||
metaTextViewDelegate: UITextViewDelegate,
|
||||
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
|
||||
composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
|
||||
composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate,
|
||||
|
@ -45,8 +46,8 @@ extension ComposeStatusSection {
|
|||
) -> UICollectionViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem> {
|
||||
UICollectionViewDiffableDataSource(collectionView: collectionView) { [
|
||||
weak customEmojiPickerInputViewModel,
|
||||
weak textEditorViewTextAttributesDelegate,
|
||||
weak textEditorViewChangeObserver,
|
||||
weak metaTextDelegate,
|
||||
weak metaTextViewDelegate,
|
||||
weak composeStatusAttachmentTableViewCellDelegate,
|
||||
weak composeStatusPollOptionCollectionViewCellDelegate,
|
||||
weak composeStatusNewPollOptionCollectionViewCellDelegate,
|
||||
|
@ -74,7 +75,7 @@ extension ComposeStatusSection {
|
|||
cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct
|
||||
// set text
|
||||
//status.emoji
|
||||
cell.statusView.activeTextLabel.configure(content: status.content, emojiDict: [:])
|
||||
// cell.statusView.activeTextLabel.configure(content: status.content, emojiDict: [:])
|
||||
// set date
|
||||
cell.statusView.dateLabel.text = status.createdAt.slowedTimeAgoSinceNow
|
||||
|
||||
|
@ -83,8 +84,17 @@ extension ComposeStatusSection {
|
|||
return cell
|
||||
case .input(let replyToStatusObjectID, let attribute):
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell
|
||||
do {
|
||||
let metaContent = try MastodonMetaContent.convert(
|
||||
document: MastodonContent(content: attribute.composeContent.value ?? "", emojis: [:])
|
||||
)
|
||||
cell.metaText.configure(content: metaContent)
|
||||
} catch {
|
||||
assertionFailure()
|
||||
}
|
||||
cell.metaText.delegate = metaTextDelegate
|
||||
cell.metaText.textView.delegate = metaTextViewDelegate
|
||||
cell.statusContentWarningEditorView.textView.text = attribute.contentWarningContent.value
|
||||
cell.textEditorView.text = attribute.composeContent.value ?? ""
|
||||
managedObjectContext.performAndWait {
|
||||
guard let replyToStatusObjectID = replyToStatusObjectID,
|
||||
let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
|
||||
|
@ -96,24 +106,22 @@ extension ComposeStatusSection {
|
|||
cell.statusView.headerInfoLabel.text = L10n.Scene.Compose.replyingToUser(replyTo.author.displayNameWithFallback)
|
||||
}
|
||||
ComposeStatusSection.configureStatusContent(cell: cell, attribute: attribute)
|
||||
cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate
|
||||
cell.textEditorViewChangeObserver = textEditorViewChangeObserver // relay
|
||||
cell.composeContent
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak collectionView] text in
|
||||
guard let collectionView = collectionView else { return }
|
||||
// self size input cell
|
||||
// needs restore content offset to resolve issue #83
|
||||
let oldContentOffset = collectionView.contentOffset
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
collectionView.layoutIfNeeded()
|
||||
collectionView.contentOffset = oldContentOffset
|
||||
|
||||
// bind input data
|
||||
attribute.composeContent.value = text
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
// cell.composeContent
|
||||
// .removeDuplicates()
|
||||
// .receive(on: DispatchQueue.main)
|
||||
// .sink { [weak collectionView] text in
|
||||
// guard let collectionView = collectionView else { return }
|
||||
// // self size input cell
|
||||
// // needs restore content offset to resolve issue #83
|
||||
// let oldContentOffset = collectionView.contentOffset
|
||||
// collectionView.collectionViewLayout.invalidateLayout()
|
||||
// collectionView.layoutIfNeeded()
|
||||
// collectionView.contentOffset = oldContentOffset
|
||||
//
|
||||
// // bind input data
|
||||
// attribute.composeContent.value = text
|
||||
// }
|
||||
// .store(in: &cell.disposeBag)
|
||||
attribute.isContentWarningComposing
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak cell, weak collectionView] isContentWarningComposing in
|
||||
|
@ -121,7 +129,7 @@ extension ComposeStatusSection {
|
|||
guard let collectionView = collectionView else { return }
|
||||
// self size input cell
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
cell.statusContentWarningEditorView.containerView.isHidden = !isContentWarningComposing
|
||||
cell.statusContentWarningEditorView.isHidden = !isContentWarningComposing
|
||||
cell.statusContentWarningEditorView.alpha = 0
|
||||
UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) {
|
||||
cell.statusContentWarningEditorView.alpha = 1
|
||||
|
@ -141,7 +149,7 @@ extension ComposeStatusSection {
|
|||
attribute.contentWarningContent.value = text
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag)
|
||||
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.metaText.textView, disposeBag: &cell.disposeBag)
|
||||
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag)
|
||||
|
||||
return cell
|
||||
|
@ -283,6 +291,30 @@ extension ComposeStatusSection {
|
|||
.store(in: &cell.disposeBag)
|
||||
}
|
||||
|
||||
static func configureStatusContent(
|
||||
cell: ComposeStatusContentTableViewCell,
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protocol CustomEmojiReplaceableTextInput: AnyObject {
|
||||
|
@ -303,16 +335,16 @@ class CustomEmojiReplaceableTextInputReference {
|
|||
}
|
||||
}
|
||||
|
||||
extension TextEditorView: CustomEmojiReplaceableTextInput {
|
||||
func insertText(_ text: String) {
|
||||
try? updateByReplacing(range: selectedRange, with: text, selectedRange: nil)
|
||||
}
|
||||
|
||||
public override var isFirstResponder: Bool {
|
||||
return isEditing
|
||||
}
|
||||
|
||||
}
|
||||
//extension TextEditorView: CustomEmojiReplaceableTextInput {
|
||||
// func insertText(_ text: String) {
|
||||
// try? updateByReplacing(range: selectedRange, with: text, selectedRange: nil)
|
||||
// }
|
||||
//
|
||||
// public override var isFirstResponder: Bool {
|
||||
// return isEditing
|
||||
// }
|
||||
//
|
||||
//}
|
||||
extension UITextField: CustomEmojiReplaceableTextInput { }
|
||||
extension UITextView: CustomEmojiReplaceableTextInput { }
|
||||
|
||||
|
|
|
@ -12,7 +12,9 @@ import os.log
|
|||
import UIKit
|
||||
import AVKit
|
||||
import Nuke
|
||||
import LinkPresentation
|
||||
import MastodonMeta
|
||||
|
||||
// import LinkPresentation
|
||||
|
||||
#if ASDK
|
||||
import AsyncDisplayKit
|
||||
|
@ -138,12 +140,15 @@ extension StatusSection {
|
|||
cell.delegate = statusTableViewCellDelegate
|
||||
switch item {
|
||||
case .root:
|
||||
cell.statusView.activeTextLabel.isAccessibilityElement = false
|
||||
// enable selection only for root
|
||||
cell.statusView.contentMetaText.textView.isSelectable = true
|
||||
cell.statusView.contentMetaText.textView.isAccessibilityElement = false
|
||||
var accessibilityElements: [Any] = []
|
||||
accessibilityElements.append(cell.statusView.avatarView)
|
||||
accessibilityElements.append(cell.statusView.nameLabel)
|
||||
accessibilityElements.append(cell.statusView.dateLabel)
|
||||
accessibilityElements.append(contentsOf: cell.statusView.activeTextLabel.createAccessibilityElements())
|
||||
// TODO: a11y
|
||||
accessibilityElements.append(cell.statusView.contentMetaText.textView)
|
||||
accessibilityElements.append(contentsOf: cell.statusView.statusMosaicImageViewContainer.imageViews)
|
||||
accessibilityElements.append(cell.statusView.playerContainerView)
|
||||
accessibilityElements.append(cell.statusView.actionToolbarContainer)
|
||||
|
@ -554,11 +559,19 @@ extension StatusSection {
|
|||
statusItemAttribute: Item.StatusAttribute
|
||||
) {
|
||||
// set content
|
||||
cell.statusView.activeTextLabel.configure(
|
||||
content: (status.reblog ?? status).content,
|
||||
emojiDict: (status.reblog ?? status).emojiDict
|
||||
)
|
||||
cell.statusView.activeTextLabel.accessibilityLanguage = (status.reblog ?? status).language
|
||||
do {
|
||||
let content = MastodonContent(
|
||||
content: (status.reblog ?? status).content,
|
||||
emojis: (status.reblog ?? status).emojiMeta
|
||||
)
|
||||
let metaContent = try MastodonMetaContent.convert(document: content)
|
||||
cell.statusView.contentMetaText.configure(content: metaContent)
|
||||
} catch {
|
||||
cell.statusView.contentMetaText.textView.text = " "
|
||||
assertionFailure()
|
||||
}
|
||||
|
||||
cell.statusView.contentMetaText.textView.accessibilityLanguage = (status.reblog ?? status).language
|
||||
|
||||
// set visibility
|
||||
if let visibility = (status.reblog ?? status).visibility {
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import Foundation
|
||||
import MastodonSDK
|
||||
import MastodonMeta
|
||||
|
||||
protocol EmojiContainer {
|
||||
var emojisData: Data? { get }
|
||||
|
@ -32,5 +33,13 @@ extension EmojiContainer {
|
|||
return dict
|
||||
}
|
||||
|
||||
var emojiMeta: MastodonContent.Emojis {
|
||||
var dict = MastodonContent.Emojis()
|
||||
for emoji in emojis ?? [] {
|
||||
dict[emoji.shortcode] = emoji.url
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,8 @@ import CoreData
|
|||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
import ActiveLabel
|
||||
import Meta
|
||||
import MetaTextView
|
||||
|
||||
// MARK: - StatusViewDelegate
|
||||
extension StatusTableViewCellDelegate where Self: StatusProvider {
|
||||
|
@ -28,6 +30,10 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
|
|||
StatusProviderFacade.responseToStatusActiveLabelAction(provider: self, cell: cell, activeLabel: activeLabel, didTapEntity: entity)
|
||||
}
|
||||
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) {
|
||||
StatusProviderFacade.responseToStatusMetaTextAction(provider: self, cell: cell, metaText: metaText, didSelectMeta: meta)
|
||||
}
|
||||
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) {
|
||||
StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell)
|
||||
}
|
||||
|
|
|
@ -12,6 +12,8 @@ import CoreData
|
|||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
import ActiveLabel
|
||||
import Meta
|
||||
import MetaTextView
|
||||
|
||||
#if ASDK
|
||||
import AsyncDisplayKit
|
||||
|
@ -149,6 +151,31 @@ extension StatusProviderFacade {
|
|||
}
|
||||
}
|
||||
|
||||
static func responseToStatusMetaTextAction(provider: StatusProvider, cell: UITableViewCell, metaText: MetaText, didSelectMeta meta: Meta) {
|
||||
switch meta {
|
||||
case .url(_, _, let url, _):
|
||||
guard let url = URL(string: url) else { return }
|
||||
if let domain = provider.context.authenticationService.activeMastodonAuthenticationBox.value?.domain, url.host == domain,
|
||||
url.pathComponents.count >= 4,
|
||||
url.pathComponents[0] == "/",
|
||||
url.pathComponents[1] == "web",
|
||||
url.pathComponents[2] == "statuses" {
|
||||
let statusID = url.pathComponents[3]
|
||||
let threadViewModel = RemoteThreadViewModel(context: provider.context, statusID: statusID)
|
||||
provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show)
|
||||
} else {
|
||||
provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil))
|
||||
}
|
||||
case .hashtag(_, let hashtag, _):
|
||||
let hashtagTimelineViewModel = HashtagTimelineViewModel(context: provider.context, hashtag: hashtag)
|
||||
provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: provider, transition: .show)
|
||||
case .mention(_, let mention, _):
|
||||
coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: mention)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
#if ASDK
|
||||
static func responseToStatusActiveLabelAction(provider: StatusProvider, node: ASCellNode, didSelectActiveEntityType type: ActiveEntityType) {
|
||||
switch type {
|
||||
|
|
|
@ -39,6 +39,8 @@ final class AutoCompleteViewController: UIViewController {
|
|||
tableView.contentInset.top = AutoCompleteViewController.chevronViewHeight
|
||||
tableView.verticalScrollIndicatorInsets.top = AutoCompleteViewController.chevronViewHeight
|
||||
tableView.showsVerticalScrollIndicator = false // avoid duplicate to the compose collection view indicator
|
||||
tableView.preservesSuperviewLayoutMargins = false
|
||||
tableView.cellLayoutMarginsFollowReadableWidth = false
|
||||
return tableView
|
||||
}()
|
||||
|
||||
|
@ -51,6 +53,9 @@ extension AutoCompleteViewController {
|
|||
|
||||
view.backgroundColor = .clear
|
||||
|
||||
// we hack the view hierarchy. Do not preserve from superview
|
||||
view.preservesSuperviewLayoutMargins = false
|
||||
|
||||
chevronView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(chevronView)
|
||||
NSLayoutConstraint.activate([
|
||||
|
|
|
@ -97,8 +97,8 @@ extension AutoCompleteTableViewCell {
|
|||
contentView.addSubview(containerStackView)
|
||||
NSLayoutConstraint.activate([
|
||||
containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor),
|
||||
containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor),
|
||||
containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor),
|
||||
containerStackView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
|
||||
containerStackView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
|
||||
])
|
||||
|
||||
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import TwitterTextEditor
|
||||
import MetaTextView
|
||||
|
||||
final class ComposeStatusContentCollectionViewCell: UICollectionViewCell {
|
||||
|
||||
|
@ -19,23 +19,36 @@ final class ComposeStatusContentCollectionViewCell: UICollectionViewCell {
|
|||
let statusContentWarningEditorView = StatusContentWarningEditorView()
|
||||
|
||||
let textEditorViewContainerView = UIView()
|
||||
let textEditorView: TextEditorView = {
|
||||
let textEditorView = TextEditorView()
|
||||
textEditorView.font = .preferredFont(forTextStyle: .body)
|
||||
textEditorView.scrollView.isScrollEnabled = false
|
||||
textEditorView.isScrollEnabled = false
|
||||
textEditorView.placeholderText = L10n.Scene.Compose.contentInputPlaceholder
|
||||
textEditorView.keyboardType = .twitter
|
||||
return textEditorView
|
||||
}()
|
||||
|
||||
// input
|
||||
weak var textEditorViewChangeObserver: TextEditorViewChangeObserver?
|
||||
static let metaTextViewTag: Int = 333
|
||||
let metaText: MetaText = {
|
||||
let metaText = MetaText()
|
||||
metaText.textView.tag = ComposeStatusContentCollectionViewCell.metaTextViewTag
|
||||
metaText.textView.isScrollEnabled = false
|
||||
metaText.textView.keyboardType = .twitter
|
||||
metaText.textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular))
|
||||
metaText.textView.attributedPlaceholder = {
|
||||
var attributes = metaText.textAttributes
|
||||
attributes[.foregroundColor] = Asset.Colors.Label.secondary.color
|
||||
return NSAttributedString(
|
||||
string: L10n.Scene.Compose.contentInputPlaceholder,
|
||||
attributes: attributes
|
||||
)
|
||||
}()
|
||||
return metaText
|
||||
}()
|
||||
|
||||
// output
|
||||
let composeContent = PassthroughSubject<String, Never>()
|
||||
let contentWarningContent = PassthroughSubject<String, Never>()
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
metaText.delegate = nil
|
||||
metaText.textView.delegate = nil
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
|
@ -90,45 +103,58 @@ extension ComposeStatusContentCollectionViewCell {
|
|||
])
|
||||
textEditorViewContainerView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
textEditorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
textEditorViewContainerView.addSubview(textEditorView)
|
||||
// textEditorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
// textEditorViewContainerView.addSubview(textEditorView)
|
||||
// NSLayoutConstraint.activate([
|
||||
// textEditorView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor),
|
||||
// textEditorView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.leadingAnchor),
|
||||
// textEditorView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.trailingAnchor),
|
||||
// textEditorView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor),
|
||||
// textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
|
||||
// ])
|
||||
// textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical)
|
||||
|
||||
metaText.textView.translatesAutoresizingMaskIntoConstraints = false
|
||||
textEditorViewContainerView.addSubview(metaText.textView)
|
||||
NSLayoutConstraint.activate([
|
||||
textEditorView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor),
|
||||
textEditorView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.leadingAnchor),
|
||||
textEditorView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.trailingAnchor),
|
||||
textEditorView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor),
|
||||
textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
|
||||
metaText.textView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor),
|
||||
metaText.textView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.leadingAnchor),
|
||||
metaText.textView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.readableContentGuide.trailingAnchor),
|
||||
metaText.textView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor),
|
||||
metaText.textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 88).priority(.defaultHigh),
|
||||
])
|
||||
textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical)
|
||||
metaText.textView.setContentCompressionResistancePriority(.required - 2, for: .vertical)
|
||||
|
||||
statusContentWarningEditorView.textView.delegate = self
|
||||
textEditorView.changeObserver = self
|
||||
//textEditorView.changeObserver = self
|
||||
|
||||
statusContentWarningEditorView.containerView.isHidden = true
|
||||
statusContentWarningEditorView.isHidden = true
|
||||
statusView.revealContentWarningButton.isHidden = true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - TextEditorViewChangeObserver
|
||||
extension ComposeStatusContentCollectionViewCell: TextEditorViewChangeObserver {
|
||||
func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) {
|
||||
defer {
|
||||
textEditorViewChangeObserver?.textEditorView(textEditorView, didChangeWithChangeResult: changeResult)
|
||||
}
|
||||
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text)
|
||||
guard changeResult.isTextChanged else { return }
|
||||
composeContent.send(textEditorView.text)
|
||||
}
|
||||
}
|
||||
//extension ComposeStatusContentCollectionViewCell: TextEditorViewChangeObserver {
|
||||
// func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) {
|
||||
// defer {
|
||||
// textEditorViewChangeObserver?.textEditorView(textEditorView, didChangeWithChangeResult: changeResult)
|
||||
// }
|
||||
//
|
||||
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text)
|
||||
// guard changeResult.isTextChanged else { return }
|
||||
// composeContent.send(textEditorView.text)
|
||||
// }
|
||||
//}
|
||||
|
||||
// MARK: - UITextViewDelegate
|
||||
extension ComposeStatusContentCollectionViewCell: UITextViewDelegate {
|
||||
|
||||
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
|
||||
// disable input line break
|
||||
guard text != "\n" else { return false }
|
||||
if textView === statusContentWarningEditorView.textView {
|
||||
// disable input line break
|
||||
guard text != "\n" else { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,9 @@ import PhotosUI
|
|||
import Kingfisher
|
||||
import MastodonSDK
|
||||
import TwitterTextEditor
|
||||
import MetaTextView
|
||||
import MastodonMeta
|
||||
import Meta
|
||||
|
||||
final class ComposeViewController: UIViewController, NeedsDependency {
|
||||
|
||||
|
@ -23,6 +26,8 @@ final class ComposeViewController: UIViewController, NeedsDependency {
|
|||
var disposeBag = Set<AnyCancellable>()
|
||||
var viewModel: ComposeViewModel!
|
||||
|
||||
let logger = Logger(subsystem: "ComposeViewController", category: "logic")
|
||||
|
||||
private var suffixedAttachmentViews: [UIView] = []
|
||||
|
||||
let publishButton: UIButton = {
|
||||
|
@ -44,19 +49,30 @@ final class ComposeViewController: UIViewController, NeedsDependency {
|
|||
return barButtonItem
|
||||
}()
|
||||
|
||||
let collectionView: ComposeCollectionView = {
|
||||
let collectionViewLayout = ComposeViewController.createLayout()
|
||||
let collectionView = ComposeCollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
|
||||
collectionView.register(ComposeRepliedToStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self))
|
||||
collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self))
|
||||
collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self))
|
||||
collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self))
|
||||
collectionView.register(ComposeStatusPollOptionAppendEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self))
|
||||
collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self))
|
||||
collectionView.backgroundColor = Asset.Scene.Compose.background.color
|
||||
collectionView.alwaysBounceVertical = true
|
||||
collectionView.keyboardDismissMode = .onDrag
|
||||
return collectionView
|
||||
// let collectionView: ComposeCollectionView = {
|
||||
// let collectionViewLayout = ComposeViewController.createLayout()
|
||||
// let collectionView = ComposeCollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
|
||||
// collectionView.register(ComposeRepliedToStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self))
|
||||
// collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self))
|
||||
// collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self))
|
||||
// collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self))
|
||||
// collectionView.register(ComposeStatusPollOptionAppendEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self))
|
||||
// collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self))
|
||||
// collectionView.backgroundColor = Asset.Scene.Compose.background.color
|
||||
// collectionView.alwaysBounceVertical = true
|
||||
// collectionView.keyboardDismissMode = .onDrag
|
||||
// return collectionView
|
||||
// }()
|
||||
|
||||
let tableView: ComposeTableView = {
|
||||
let tableView = ComposeTableView()
|
||||
tableView.register(ComposeRepliedToStatusContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self))
|
||||
tableView.register(ComposeStatusContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusContentTableViewCell.self))
|
||||
tableView.backgroundColor = Asset.Scene.Compose.background.color
|
||||
tableView.alwaysBounceVertical = true
|
||||
tableView.separatorStyle = .none
|
||||
tableView.tableFooterView = UIView()
|
||||
return tableView
|
||||
}()
|
||||
|
||||
var systemKeyboardHeight: CGFloat = .zero {
|
||||
|
@ -149,15 +165,25 @@ extension ComposeViewController {
|
|||
navigationItem.rightBarButtonItem = publishBarButtonItem
|
||||
publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside)
|
||||
|
||||
collectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(collectionView)
|
||||
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(tableView)
|
||||
NSLayoutConstraint.activate([
|
||||
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
// collectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||
// view.addSubview(collectionView)
|
||||
// NSLayoutConstraint.activate([
|
||||
// collectionView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
// collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
// collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
// collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
// ])
|
||||
|
||||
composeToolbarView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(composeToolbarView)
|
||||
composeToolbarViewBottomLayoutConstraint = view.bottomAnchor.constraint(equalTo: composeToolbarView.bottomAnchor)
|
||||
|
@ -179,20 +205,40 @@ extension ComposeViewController {
|
|||
view.bottomAnchor.constraint(equalTo: composeToolbarBackgroundView.bottomAnchor),
|
||||
])
|
||||
|
||||
collectionView.delegate = self
|
||||
tableView.delegate = self
|
||||
viewModel.setupDiffableDataSource(
|
||||
for: collectionView,
|
||||
dependency: self,
|
||||
customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel,
|
||||
textEditorViewTextAttributesDelegate: self,
|
||||
textEditorViewChangeObserver: self,
|
||||
composeStatusAttachmentTableViewCellDelegate: self,
|
||||
composeStatusPollOptionCollectionViewCellDelegate: self,
|
||||
composeStatusNewPollOptionCollectionViewCellDelegate: self,
|
||||
composeStatusPollExpiresOptionCollectionViewCellDelegate: self
|
||||
tableView: tableView,
|
||||
metaTextDelegate: self,
|
||||
metaTextViewDelegate: self,
|
||||
customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel
|
||||
)
|
||||
let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:)))
|
||||
collectionView.addGestureRecognizer(longPressReorderGesture)
|
||||
|
||||
viewModel.composeStatusAttribute.composeContent
|
||||
.removeDuplicates()
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.beginUpdates()
|
||||
self.tableView.endUpdates()
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// collectionView.delegate = self
|
||||
// viewModel.setupDiffableDataSource(
|
||||
// for: collectionView,
|
||||
// dependency: self,
|
||||
// customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel,
|
||||
// metaTextDelegate: self,
|
||||
// metaTextViewDelegate: self,
|
||||
// composeStatusAttachmentTableViewCellDelegate: self,
|
||||
// composeStatusPollOptionCollectionViewCellDelegate: self,
|
||||
// composeStatusNewPollOptionCollectionViewCellDelegate: self,
|
||||
// composeStatusPollExpiresOptionCollectionViewCellDelegate: self
|
||||
// )
|
||||
// let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:)))
|
||||
// collectionView.addGestureRecognizer(longPressReorderGesture)
|
||||
|
||||
customEmojiPickerInputView.collectionView.delegate = self
|
||||
viewModel.customEmojiPickerInputViewModel.customEmojiPickerInputView = customEmojiPickerInputView
|
||||
|
@ -227,8 +273,8 @@ extension ComposeViewController {
|
|||
// update keyboard background color
|
||||
|
||||
guard isShow, state == .dock else {
|
||||
self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin
|
||||
self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin
|
||||
self.tableView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin
|
||||
self.tableView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin
|
||||
|
||||
if let superView = self.autoCompleteViewController.tableView.superview {
|
||||
let autoCompleteTableViewBottomInset: CGFloat = {
|
||||
|
@ -263,18 +309,18 @@ extension ComposeViewController {
|
|||
self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset
|
||||
|
||||
// adjust inset for collectionView
|
||||
let contentFrame = self.view.convert(self.collectionView.frame, to: nil)
|
||||
let contentFrame = self.view.convert(self.tableView.frame, to: nil)
|
||||
let padding = contentFrame.maxY + extraMargin - endFrame.minY
|
||||
guard padding > 0 else {
|
||||
self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin
|
||||
self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin
|
||||
self.tableView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin
|
||||
self.tableView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin
|
||||
|
||||
self.updateKeyboardBackground(isKeyboardDisplay: false)
|
||||
return
|
||||
}
|
||||
|
||||
self.collectionView.contentInset.bottom = padding
|
||||
self.collectionView.verticalScrollIndicatorInsets.bottom = padding
|
||||
self.tableView.contentInset.bottom = padding
|
||||
self.tableView.verticalScrollIndicatorInsets.bottom = padding
|
||||
UIView.animate(withDuration: 0.3) {
|
||||
self.composeToolbarViewBottomLayoutConstraint.constant = endFrame.height
|
||||
self.view.layoutIfNeeded()
|
||||
|
@ -292,15 +338,16 @@ extension ComposeViewController {
|
|||
if self.autoCompleteViewController.view.superview == nil {
|
||||
self.autoCompleteViewController.view.frame = self.view.bounds
|
||||
// add to container view. seealso: `viewDidLayoutSubviews()`
|
||||
textEditorView.superview!.addSubview(self.autoCompleteViewController.view)
|
||||
self.viewModel.composeStatusContentTableViewCell.textEditorViewContainerView.addSubview(self.autoCompleteViewController.view)
|
||||
self.addChild(self.autoCompleteViewController)
|
||||
self.autoCompleteViewController.didMove(toParent: self)
|
||||
self.autoCompleteViewController.view.isHidden = true
|
||||
self.collectionView.autoCompleteViewController = self.autoCompleteViewController
|
||||
self.tableView.autoCompleteViewController = self.autoCompleteViewController
|
||||
}
|
||||
self.updateAutoCompleteViewControllerLayout()
|
||||
self.autoCompleteViewController.view.isHidden = info == nil
|
||||
guard let info = info else { return }
|
||||
let symbolBoundingRectInContainer = textEditorView.convert(info.symbolBoundingRect, to: self.autoCompleteViewController.chevronView)
|
||||
let symbolBoundingRectInContainer = textEditorView.textView.convert(info.symbolBoundingRect, to: self.autoCompleteViewController.chevronView)
|
||||
self.autoCompleteViewController.view.frame.origin.y = info.textBoundingRect.maxY
|
||||
self.autoCompleteViewController.viewModel.symbolBoundingRect.value = symbolBoundingRectInContainer
|
||||
self.autoCompleteViewController.viewModel.inputText.value = String(info.inputText)
|
||||
|
@ -423,9 +470,9 @@ extension ComposeViewController {
|
|||
guard repliedToCellFrame != .zero else { return }
|
||||
switch collectionViewState {
|
||||
case .fold:
|
||||
self.collectionView.contentInset.top = -repliedToCellFrame.height
|
||||
self.tableView.contentInset.top = -repliedToCellFrame.height
|
||||
case .expand:
|
||||
self.collectionView.contentInset.top = 0
|
||||
self.tableView.contentInset.top = 0
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
@ -449,13 +496,17 @@ extension ComposeViewController {
|
|||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
updateAutoCompleteViewControllerLayout()
|
||||
}
|
||||
|
||||
// pin autoCompleteViewController frame to window
|
||||
func updateAutoCompleteViewControllerLayout() {
|
||||
// pin autoCompleteViewController frame to current view
|
||||
if let containerView = autoCompleteViewController.view.superview {
|
||||
let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: nil)
|
||||
let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: view)
|
||||
if viewFrameInWindow.origin.x != 0 {
|
||||
autoCompleteViewController.view.frame.origin.x = -viewFrameInWindow.origin.x
|
||||
}
|
||||
autoCompleteViewController.view.frame.size.width = view.frame.width
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -463,98 +514,68 @@ extension ComposeViewController {
|
|||
|
||||
extension ComposeViewController {
|
||||
|
||||
private func textEditorView() -> TextEditorView? {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
||||
let items = diffableDataSource.snapshot().itemIdentifiers
|
||||
for item in items {
|
||||
switch item {
|
||||
case .input:
|
||||
guard let indexPath = diffableDataSource.indexPath(for: item),
|
||||
let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusContentCollectionViewCell else {
|
||||
continue
|
||||
}
|
||||
return cell.textEditorView
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
private func textEditorView() -> MetaText? {
|
||||
return viewModel.composeStatusContentTableViewCell.metaText
|
||||
}
|
||||
|
||||
private func markTextEditorViewBecomeFirstResponser() {
|
||||
textEditorView()?.isEditing = true
|
||||
textEditorView()?.textView.becomeFirstResponder()
|
||||
}
|
||||
|
||||
private func contentWarningEditorTextView() -> UITextView? {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
||||
let items = diffableDataSource.snapshot().itemIdentifiers
|
||||
for item in items {
|
||||
switch item {
|
||||
case .input:
|
||||
guard let indexPath = diffableDataSource.indexPath(for: item),
|
||||
let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusContentCollectionViewCell else {
|
||||
continue
|
||||
}
|
||||
return cell.statusContentWarningEditorView.textView
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
viewModel.composeStatusContentTableViewCell.statusContentWarningEditorView.textView
|
||||
}
|
||||
|
||||
private func pollOptionCollectionViewCell(of item: ComposeStatusItem) -> ComposeStatusPollOptionCollectionViewCell? {
|
||||
guard case .pollOption = item else { return nil }
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
||||
guard let indexPath = diffableDataSource.indexPath(for: item),
|
||||
let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else {
|
||||
return nil
|
||||
}
|
||||
// private func pollOptionCollectionViewCell(of item: ComposeStatusItem) -> ComposeStatusPollOptionCollectionViewCell? {
|
||||
// guard case .pollOption = item else { return nil }
|
||||
// guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
||||
// guard let indexPath = diffableDataSource.indexPath(for: item),
|
||||
// let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else {
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// return cell
|
||||
// }
|
||||
|
||||
return cell
|
||||
}
|
||||
// private func firstPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? {
|
||||
// guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
||||
// let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll)
|
||||
// let firstPollItem = items.first { item -> Bool in
|
||||
// guard case .pollOption = item else { return false }
|
||||
// return true
|
||||
// }
|
||||
//
|
||||
// guard let item = firstPollItem else {
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// return pollOptionCollectionViewCell(of: item)
|
||||
// }
|
||||
|
||||
private func firstPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
||||
let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll)
|
||||
let firstPollItem = items.first { item -> Bool in
|
||||
guard case .pollOption = item else { return false }
|
||||
return true
|
||||
}
|
||||
// private func lastPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? {
|
||||
// guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
||||
// let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll)
|
||||
// let lastPollItem = items.last { item -> Bool in
|
||||
// guard case .pollOption = item else { return false }
|
||||
// return true
|
||||
// }
|
||||
//
|
||||
// guard let item = lastPollItem else {
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// return pollOptionCollectionViewCell(of: item)
|
||||
// }
|
||||
|
||||
guard let item = firstPollItem else {
|
||||
return nil
|
||||
}
|
||||
// private func markFirstPollOptionCollectionViewCellBecomeFirstResponser() {
|
||||
// guard let cell = firstPollOptionCollectionViewCell() else { return }
|
||||
// cell.pollOptionView.optionTextField.becomeFirstResponder()
|
||||
// }
|
||||
|
||||
return pollOptionCollectionViewCell(of: item)
|
||||
}
|
||||
|
||||
private func lastPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
|
||||
let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll)
|
||||
let lastPollItem = items.last { item -> Bool in
|
||||
guard case .pollOption = item else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
guard let item = lastPollItem else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return pollOptionCollectionViewCell(of: item)
|
||||
}
|
||||
|
||||
private func markFirstPollOptionCollectionViewCellBecomeFirstResponser() {
|
||||
guard let cell = firstPollOptionCollectionViewCell() else { return }
|
||||
cell.pollOptionView.optionTextField.becomeFirstResponder()
|
||||
}
|
||||
|
||||
private func markLastPollOptionCollectionViewCellBecomeFirstResponser() {
|
||||
guard let cell = lastPollOptionCollectionViewCell() else { return }
|
||||
cell.pollOptionView.optionTextField.becomeFirstResponder()
|
||||
}
|
||||
// private func markLastPollOptionCollectionViewCellBecomeFirstResponser() {
|
||||
// guard let cell = lastPollOptionCollectionViewCell() else { return }
|
||||
// cell.pollOptionView.optionTextField.becomeFirstResponder()
|
||||
// }
|
||||
|
||||
private func showDismissConfirmAlertController() {
|
||||
let alertController = UIAlertController(
|
||||
|
@ -632,42 +653,178 @@ extension ComposeViewController {
|
|||
}
|
||||
|
||||
// seealso: ComposeViewModel.setupDiffableDataSource(…)
|
||||
@objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) {
|
||||
switch(sender.state) {
|
||||
case .began:
|
||||
guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)),
|
||||
let cell = collectionView.cellForItem(at: selectedIndexPath) as? ComposeStatusPollOptionCollectionViewCell else {
|
||||
break
|
||||
}
|
||||
// check if pressing reorder bar no not
|
||||
let locationInCell = sender.location(in: cell)
|
||||
guard cell.reorderBarImageView.frame.contains(locationInCell) else {
|
||||
return
|
||||
}
|
||||
// @objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) {
|
||||
// switch(sender.state) {
|
||||
// case .began:
|
||||
// guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)),
|
||||
// let cell = collectionView.cellForItem(at: selectedIndexPath) as? ComposeStatusPollOptionCollectionViewCell else {
|
||||
// break
|
||||
// }
|
||||
// // check if pressing reorder bar no not
|
||||
// let locationInCell = sender.location(in: cell)
|
||||
// guard cell.reorderBarImageView.frame.contains(locationInCell) else {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// collectionView.beginInteractiveMovementForItem(at: selectedIndexPath)
|
||||
// case .changed:
|
||||
// guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)),
|
||||
// let diffableDataSource = viewModel.diffableDataSource else {
|
||||
// break
|
||||
// }
|
||||
// guard let item = diffableDataSource.itemIdentifier(for: selectedIndexPath),
|
||||
// case .pollOption = item else {
|
||||
// collectionView.cancelInteractiveMovement()
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// var position = sender.location(in: collectionView)
|
||||
// position.x = collectionView.frame.width * 0.5
|
||||
// collectionView.updateInteractiveMovementTargetPosition(position)
|
||||
// case .ended:
|
||||
// collectionView.endInteractiveMovement()
|
||||
// collectionView.reloadData()
|
||||
// default:
|
||||
// collectionView.cancelInteractiveMovement()
|
||||
// }
|
||||
// }
|
||||
|
||||
collectionView.beginInteractiveMovementForItem(at: selectedIndexPath)
|
||||
case .changed:
|
||||
guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)),
|
||||
let diffableDataSource = viewModel.diffableDataSource else {
|
||||
break
|
||||
}
|
||||
guard let item = diffableDataSource.itemIdentifier(for: selectedIndexPath),
|
||||
case .pollOption = item else {
|
||||
collectionView.cancelInteractiveMovement()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var position = sender.location(in: collectionView)
|
||||
position.x = collectionView.frame.width * 0.5
|
||||
collectionView.updateInteractiveMovementTargetPosition(position)
|
||||
case .ended:
|
||||
collectionView.endInteractiveMovement()
|
||||
collectionView.reloadData()
|
||||
default:
|
||||
collectionView.cancelInteractiveMovement()
|
||||
// MARK: - MetaTextDelegate
|
||||
extension ComposeViewController: MetaTextDelegate {
|
||||
func metaText(_ metaText: MetaText, processEditing textStorage: MetaTextStorage) -> MetaContent? {
|
||||
let string = metaText.textStorage.string
|
||||
let content = MastodonContent(
|
||||
content: string,
|
||||
emojis: viewModel.customEmojiViewModel.value?.emojiMapping.value ?? [:]
|
||||
)
|
||||
let metaContent = MastodonMetaContent.convert(text: content)
|
||||
return metaContent
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITextViewDelegate
|
||||
extension ComposeViewController: UITextViewDelegate {
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
if textView.tag == ComposeStatusContentCollectionViewCell.metaTextViewTag {
|
||||
// update model
|
||||
guard let metaText = textEditorView() else { return }
|
||||
let backedString = metaText.backedString
|
||||
viewModel.composeStatusAttribute.composeContent.value = backedString
|
||||
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(backedString)")
|
||||
|
||||
// configure auto completion
|
||||
setupAutoComplete(for: textView)
|
||||
}
|
||||
}
|
||||
|
||||
struct AutoCompleteInfo {
|
||||
// model
|
||||
let inputText: Substring
|
||||
// range
|
||||
let symbolRange: Range<String.Index>
|
||||
let symbolString: Substring
|
||||
let toCursorRange: Range<String.Index>
|
||||
let toCursorString: Substring
|
||||
let toHighlightEndRange: Range<String.Index>
|
||||
let toHighlightEndString: Substring
|
||||
// geometry
|
||||
var textBoundingRect: CGRect = .zero
|
||||
var symbolBoundingRect: CGRect = .zero
|
||||
}
|
||||
|
||||
private func setupAutoComplete(for textView: UITextView) {
|
||||
guard var autoCompletion = ComposeViewController.scanAutoCompleteInfo(textView: textView) else {
|
||||
viewModel.autoCompleteInfo.value = nil
|
||||
return
|
||||
}
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompletion.toHighlightEndString), String(autoCompletion.toCursorString))
|
||||
|
||||
// get layout text bounding rect
|
||||
var glyphRange = NSRange()
|
||||
textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.toCursorRange, in: textView.text), actualGlyphRange: &glyphRange)
|
||||
let textContainer = textView.layoutManager.textContainers[0]
|
||||
let textBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
||||
|
||||
let retryLayoutTimes = viewModel.autoCompleteRetryLayoutTimes.value
|
||||
guard textBoundingRect.size != .zero else {
|
||||
viewModel.autoCompleteRetryLayoutTimes.value += 1
|
||||
// avoid infinite loop
|
||||
guard retryLayoutTimes < 3 else { return }
|
||||
// needs retry calculate layout when the rect position changing
|
||||
DispatchQueue.main.async {
|
||||
self.setupAutoComplete(for: textView)
|
||||
}
|
||||
return
|
||||
}
|
||||
viewModel.autoCompleteRetryLayoutTimes.value = 0
|
||||
|
||||
// get symbol bounding rect
|
||||
textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.symbolRange, in: textView.text), actualGlyphRange: &glyphRange)
|
||||
let symbolBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
||||
|
||||
// set bounding rect and trigger layout
|
||||
autoCompletion.textBoundingRect = textBoundingRect
|
||||
autoCompletion.symbolBoundingRect = symbolBoundingRect
|
||||
viewModel.autoCompleteInfo.value = autoCompletion
|
||||
}
|
||||
|
||||
private static func scanAutoCompleteInfo(textView: UITextView) -> AutoCompleteInfo? {
|
||||
guard let text = textView.text,
|
||||
textView.selectedRange.location > 0, !text.isEmpty,
|
||||
let selectedRange = Range(textView.selectedRange, in: text) else {
|
||||
return nil
|
||||
}
|
||||
let cursorIndex = selectedRange.upperBound
|
||||
let _highlightStartIndex: String.Index? = {
|
||||
var index = text.index(before: cursorIndex)
|
||||
while index > text.startIndex {
|
||||
let char = text[index]
|
||||
if char == "@" || char == "#" || char == ":" {
|
||||
return index
|
||||
}
|
||||
index = text.index(before: index)
|
||||
}
|
||||
assert(index == text.startIndex)
|
||||
let char = text[index]
|
||||
if char == "@" || char == "#" || char == ":" {
|
||||
return index
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
guard let highlightStartIndex = _highlightStartIndex else { return nil }
|
||||
let scanRange = NSRange(highlightStartIndex..<text.endIndex, in: text)
|
||||
|
||||
guard let match = text.firstMatch(pattern: MastodonRegex.autoCompletePattern, options: [], range: scanRange) else { return nil }
|
||||
guard let matchRange = Range(match.range(at: 0), in: text) else { return nil }
|
||||
let matchStartIndex = matchRange.lowerBound
|
||||
let matchEndIndex = matchRange.upperBound
|
||||
|
||||
guard matchStartIndex == highlightStartIndex, matchEndIndex >= cursorIndex else { return nil }
|
||||
let symbolRange = highlightStartIndex..<text.index(after: highlightStartIndex)
|
||||
let symbolString = text[symbolRange]
|
||||
let toCursorRange = highlightStartIndex..<cursorIndex
|
||||
let toCursorString = text[toCursorRange]
|
||||
let toHighlightEndRange = matchStartIndex..<matchEndIndex
|
||||
let toHighlightEndString = text[toHighlightEndRange]
|
||||
|
||||
let inputText = toHighlightEndString
|
||||
let autoCompleteInfo = AutoCompleteInfo(
|
||||
inputText: inputText,
|
||||
symbolRange: symbolRange,
|
||||
symbolString: symbolString,
|
||||
toCursorRange: toCursorRange,
|
||||
toCursorString: toCursorString,
|
||||
toHighlightEndRange: toHighlightEndRange,
|
||||
toHighlightEndString: toHighlightEndString
|
||||
)
|
||||
return autoCompleteInfo
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - TextEditorViewTextAttributesDelegate
|
||||
|
@ -811,117 +968,6 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate {
|
|||
|
||||
}
|
||||
|
||||
// MARK: - TextEditorViewChangeObserver
|
||||
extension ComposeViewController: TextEditorViewChangeObserver {
|
||||
|
||||
func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) {
|
||||
guard var autoCompletion = ComposeViewController.scanAutoCompleteInfo(textEditorView: textEditorView) else {
|
||||
viewModel.autoCompleteInfo.value = nil
|
||||
return
|
||||
}
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompletion.toHighlightEndString), String(autoCompletion.toCursorString))
|
||||
|
||||
// get layout text bounding rect
|
||||
var glyphRange = NSRange()
|
||||
textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.toCursorRange, in: textEditorView.text), actualGlyphRange: &glyphRange)
|
||||
let textContainer = textEditorView.layoutManager.textContainers[0]
|
||||
let textBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
||||
|
||||
let retryLayoutTimes = viewModel.autoCompleteRetryLayoutTimes.value
|
||||
guard textBoundingRect.size != .zero else {
|
||||
viewModel.autoCompleteRetryLayoutTimes.value += 1
|
||||
// avoid infinite loop
|
||||
guard retryLayoutTimes < 3 else { return }
|
||||
// needs retry calculate layout when the rect position changing
|
||||
DispatchQueue.main.async {
|
||||
self.textEditorView(textEditorView, didChangeWithChangeResult: changeResult)
|
||||
}
|
||||
return
|
||||
}
|
||||
viewModel.autoCompleteRetryLayoutTimes.value = 0
|
||||
|
||||
// get symbol bounding rect
|
||||
textEditorView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.symbolRange, in: textEditorView.text), actualGlyphRange: &glyphRange)
|
||||
let symbolBoundingRect = textEditorView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
||||
|
||||
// set bounding rect and trigger layout
|
||||
autoCompletion.textBoundingRect = textBoundingRect
|
||||
autoCompletion.symbolBoundingRect = symbolBoundingRect
|
||||
viewModel.autoCompleteInfo.value = autoCompletion
|
||||
}
|
||||
|
||||
struct AutoCompleteInfo {
|
||||
// model
|
||||
let inputText: Substring
|
||||
// range
|
||||
let symbolRange: Range<String.Index>
|
||||
let symbolString: Substring
|
||||
let toCursorRange: Range<String.Index>
|
||||
let toCursorString: Substring
|
||||
let toHighlightEndRange: Range<String.Index>
|
||||
let toHighlightEndString: Substring
|
||||
// geometry
|
||||
var textBoundingRect: CGRect = .zero
|
||||
var symbolBoundingRect: CGRect = .zero
|
||||
}
|
||||
|
||||
private static func scanAutoCompleteInfo(textEditorView: TextEditorView) -> AutoCompleteInfo? {
|
||||
let text = textEditorView.text
|
||||
|
||||
guard textEditorView.selectedRange.location > 0, !text.isEmpty,
|
||||
let selectedRange = Range(textEditorView.selectedRange, in: text) else {
|
||||
return nil
|
||||
}
|
||||
let cursorIndex = selectedRange.upperBound
|
||||
let _highlightStartIndex: String.Index? = {
|
||||
var index = text.index(before: cursorIndex)
|
||||
while index > text.startIndex {
|
||||
let char = text[index]
|
||||
if char == "@" || char == "#" || char == ":" {
|
||||
return index
|
||||
}
|
||||
index = text.index(before: index)
|
||||
}
|
||||
assert(index == text.startIndex)
|
||||
let char = text[index]
|
||||
if char == "@" || char == "#" || char == ":" {
|
||||
return index
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
guard let highlightStartIndex = _highlightStartIndex else { return nil }
|
||||
let scanRange = NSRange(highlightStartIndex..<text.endIndex, in: text)
|
||||
|
||||
guard let match = text.firstMatch(pattern: MastodonRegex.autoCompletePattern, options: [], range: scanRange) else { return nil }
|
||||
guard let matchRange = Range(match.range(at: 0), in: text) else { return nil }
|
||||
let matchStartIndex = matchRange.lowerBound
|
||||
let matchEndIndex = matchRange.upperBound
|
||||
|
||||
guard matchStartIndex == highlightStartIndex, matchEndIndex >= cursorIndex else { return nil }
|
||||
let symbolRange = highlightStartIndex..<text.index(after: highlightStartIndex)
|
||||
let symbolString = text[symbolRange]
|
||||
let toCursorRange = highlightStartIndex..<cursorIndex
|
||||
let toCursorString = text[toCursorRange]
|
||||
let toHighlightEndRange = matchStartIndex..<matchEndIndex
|
||||
let toHighlightEndString = text[toHighlightEndRange]
|
||||
|
||||
let inputText = toHighlightEndString
|
||||
let autoCompleteInfo = AutoCompleteInfo(
|
||||
inputText: inputText,
|
||||
symbolRange: symbolRange,
|
||||
symbolString: symbolString,
|
||||
toCursorRange: toCursorRange,
|
||||
toCursorString: toCursorString,
|
||||
toHighlightEndRange: toHighlightEndRange,
|
||||
toHighlightEndString: toHighlightEndString
|
||||
)
|
||||
return autoCompleteInfo
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - ComposeToolbarViewDelegate
|
||||
extension ComposeViewController: ComposeToolbarViewDelegate {
|
||||
|
||||
|
@ -944,14 +990,14 @@ extension ComposeViewController: ComposeToolbarViewDelegate {
|
|||
viewModel.pollOptionAttributes.value = [ComposeStatusItem.ComposePollOptionAttribute(), ComposeStatusItem.ComposePollOptionAttribute()]
|
||||
}
|
||||
|
||||
if viewModel.isPollComposing.value {
|
||||
// Magic RunLoop
|
||||
DispatchQueue.main.async {
|
||||
self.markFirstPollOptionCollectionViewCellBecomeFirstResponser()
|
||||
}
|
||||
} else {
|
||||
markTextEditorViewBecomeFirstResponser()
|
||||
}
|
||||
// if viewModel.isPollComposing.value {
|
||||
// // Magic RunLoop
|
||||
// DispatchQueue.main.async {
|
||||
// self.markFirstPollOptionCollectionViewCellBecomeFirstResponser()
|
||||
// }
|
||||
// } else {
|
||||
// markTextEditorViewBecomeFirstResponser()
|
||||
// }
|
||||
}
|
||||
|
||||
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton) {
|
||||
|
@ -984,7 +1030,7 @@ extension ComposeViewController: ComposeToolbarViewDelegate {
|
|||
// MARK: - UIScrollViewDelegate
|
||||
extension ComposeViewController {
|
||||
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
|
||||
guard scrollView === collectionView else { return }
|
||||
guard scrollView === tableView else { return }
|
||||
|
||||
let repliedToCellFrame = viewModel.repliedToCellFrame.value
|
||||
guard repliedToCellFrame != .zero else { return }
|
||||
|
@ -1007,6 +1053,9 @@ extension ComposeViewController {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
extension ComposeViewController: UITableViewDelegate { }
|
||||
|
||||
// MARK: - UICollectionViewDelegate
|
||||
extension ComposeViewController: UICollectionViewDelegate {
|
||||
|
||||
|
@ -1018,26 +1067,13 @@ extension ComposeViewController: UICollectionViewDelegate {
|
|||
let item = diffableDataSource.itemIdentifier(for: indexPath)
|
||||
guard case let .emoji(attribute) = item else { return }
|
||||
let emoji = attribute.emoji
|
||||
let textEditorView = self.textEditorView()
|
||||
|
||||
// make click sound
|
||||
UIDevice.current.playInputClick()
|
||||
|
||||
// retrieve active text input and insert emoji
|
||||
// the leading and trailing space is REQUIRED to fix `UITextStorage` layout issue
|
||||
let reference = viewModel.customEmojiPickerInputViewModel.insertText(" :\(emoji.shortcode): ")
|
||||
|
||||
// workaround: non-user interactive change do not trigger value update event
|
||||
if reference?.value === textEditorView {
|
||||
viewModel.composeStatusAttribute.composeContent.value = textEditorView?.text
|
||||
// update text storage
|
||||
textEditorView?.setNeedsUpdateTextAttributes()
|
||||
// collection self-size
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.collectionView.collectionViewLayout.invalidateLayout()
|
||||
|
||||
// make click sound
|
||||
UIDevice.current.playInputClick()
|
||||
}
|
||||
}
|
||||
// the trailing space is REQUIRED to make regex happy
|
||||
_ = viewModel.customEmojiPickerInputViewModel.insertText(":\(emoji.shortcode): ")
|
||||
} else {
|
||||
// do nothing
|
||||
}
|
||||
|
@ -1124,19 +1160,19 @@ extension ComposeViewController: UIDocumentPickerDelegate {
|
|||
extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelegate {
|
||||
|
||||
func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
guard let indexPath = collectionView.indexPath(for: cell) else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
guard case let .attachment(attachmentService) = item else { return }
|
||||
|
||||
var attachmentServices = viewModel.attachmentServices.value
|
||||
guard let index = attachmentServices.firstIndex(of: attachmentService) else { return }
|
||||
let removedItem = attachmentServices[index]
|
||||
attachmentServices.remove(at: index)
|
||||
viewModel.attachmentServices.value = attachmentServices
|
||||
|
||||
// cancel task
|
||||
removedItem.disposeBag.removeAll()
|
||||
// guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
// guard let indexPath = collectionView.indexPath(for: cell) else { return }
|
||||
// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
// guard case let .attachment(attachmentService) = item else { return }
|
||||
//
|
||||
// var attachmentServices = viewModel.attachmentServices.value
|
||||
// guard let index = attachmentServices.firstIndex(of: attachmentService) else { return }
|
||||
// let removedItem = attachmentServices[index]
|
||||
// attachmentServices.remove(at: index)
|
||||
// viewModel.attachmentServices.value = attachmentServices
|
||||
//
|
||||
// // cancel task
|
||||
// removedItem.disposeBag.removeAll()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1154,72 +1190,72 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega
|
|||
|
||||
// handle delete backward event for poll option input
|
||||
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) {
|
||||
guard (text ?? "").isEmpty else { return }
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
guard let indexPath = collectionView.indexPath(for: cell) else { return }
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
guard case let .pollOption(attribute) = item else { return }
|
||||
|
||||
var pollAttributes = viewModel.pollOptionAttributes.value
|
||||
guard let index = pollAttributes.firstIndex(of: attribute) else { return }
|
||||
|
||||
// mark previous (fallback to next) item of removed middle poll option become first responder
|
||||
let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll)
|
||||
if let indexOfItem = pollItems.firstIndex(of: item), index > 0 {
|
||||
func cellBeforeRemoved() -> ComposeStatusPollOptionCollectionViewCell? {
|
||||
guard index > 0 else { return nil }
|
||||
let indexBeforeRemoved = pollItems.index(before: indexOfItem)
|
||||
let itemBeforeRemoved = pollItems[indexBeforeRemoved]
|
||||
return pollOptionCollectionViewCell(of: itemBeforeRemoved)
|
||||
}
|
||||
|
||||
func cellAfterRemoved() -> ComposeStatusPollOptionCollectionViewCell? {
|
||||
guard index < pollItems.count - 1 else { return nil }
|
||||
let indexAfterRemoved = pollItems.index(after: index)
|
||||
let itemAfterRemoved = pollItems[indexAfterRemoved]
|
||||
return pollOptionCollectionViewCell(of: itemAfterRemoved)
|
||||
}
|
||||
|
||||
var cell: ComposeStatusPollOptionCollectionViewCell? = cellBeforeRemoved()
|
||||
if cell == nil {
|
||||
cell = cellAfterRemoved()
|
||||
}
|
||||
cell?.pollOptionView.optionTextField.becomeFirstResponder()
|
||||
}
|
||||
|
||||
guard pollAttributes.count > 2 else {
|
||||
return
|
||||
}
|
||||
pollAttributes.remove(at: index)
|
||||
|
||||
// update data source
|
||||
viewModel.pollOptionAttributes.value = pollAttributes
|
||||
// guard (text ?? "").isEmpty else { return }
|
||||
// guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
// guard let indexPath = collectionView.indexPath(for: cell) else { return }
|
||||
// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
// guard case let .pollOption(attribute) = item else { return }
|
||||
//
|
||||
// var pollAttributes = viewModel.pollOptionAttributes.value
|
||||
// guard let index = pollAttributes.firstIndex(of: attribute) else { return }
|
||||
//
|
||||
// // mark previous (fallback to next) item of removed middle poll option become first responder
|
||||
// let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll)
|
||||
// if let indexOfItem = pollItems.firstIndex(of: item), index > 0 {
|
||||
// func cellBeforeRemoved() -> ComposeStatusPollOptionCollectionViewCell? {
|
||||
// guard index > 0 else { return nil }
|
||||
// let indexBeforeRemoved = pollItems.index(before: indexOfItem)
|
||||
// let itemBeforeRemoved = pollItems[indexBeforeRemoved]
|
||||
// return pollOptionCollectionViewCell(of: itemBeforeRemoved)
|
||||
// }
|
||||
//
|
||||
// func cellAfterRemoved() -> ComposeStatusPollOptionCollectionViewCell? {
|
||||
// guard index < pollItems.count - 1 else { return nil }
|
||||
// let indexAfterRemoved = pollItems.index(after: index)
|
||||
// let itemAfterRemoved = pollItems[indexAfterRemoved]
|
||||
// return pollOptionCollectionViewCell(of: itemAfterRemoved)
|
||||
// }
|
||||
//
|
||||
// var cell: ComposeStatusPollOptionCollectionViewCell? = cellBeforeRemoved()
|
||||
// if cell == nil {
|
||||
// cell = cellAfterRemoved()
|
||||
// }
|
||||
// cell?.pollOptionView.optionTextField.becomeFirstResponder()
|
||||
// }
|
||||
//
|
||||
// guard pollAttributes.count > 2 else {
|
||||
// return
|
||||
// }
|
||||
// pollAttributes.remove(at: index)
|
||||
//
|
||||
// // update data source
|
||||
// viewModel.pollOptionAttributes.value = pollAttributes
|
||||
}
|
||||
|
||||
// handle keyboard return event for poll option input
|
||||
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) {
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
guard let indexPath = collectionView.indexPath(for: cell) else { return }
|
||||
let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll).filter { item in
|
||||
guard case .pollOption = item else { return false }
|
||||
return true
|
||||
}
|
||||
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
guard let index = pollItems.firstIndex(of: item) else { return }
|
||||
|
||||
if index == pollItems.count - 1 {
|
||||
// is the last
|
||||
viewModel.createNewPollOptionIfPossible()
|
||||
DispatchQueue.main.async {
|
||||
self.markLastPollOptionCollectionViewCellBecomeFirstResponser()
|
||||
}
|
||||
} else {
|
||||
// not the last
|
||||
let indexAfter = pollItems.index(after: index)
|
||||
let itemAfter = pollItems[indexAfter]
|
||||
let cell = pollOptionCollectionViewCell(of: itemAfter)
|
||||
cell?.pollOptionView.optionTextField.becomeFirstResponder()
|
||||
}
|
||||
// guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
// guard let indexPath = collectionView.indexPath(for: cell) else { return }
|
||||
// let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll).filter { item in
|
||||
// guard case .pollOption = item else { return false }
|
||||
// return true
|
||||
// }
|
||||
// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||
// guard let index = pollItems.firstIndex(of: item) else { return }
|
||||
//
|
||||
// if index == pollItems.count - 1 {
|
||||
// // is the last
|
||||
// viewModel.createNewPollOptionIfPossible()
|
||||
// DispatchQueue.main.async {
|
||||
// self.markLastPollOptionCollectionViewCellBecomeFirstResponser()
|
||||
// }
|
||||
// } else {
|
||||
// // not the last
|
||||
// let indexAfter = pollItems.index(after: index)
|
||||
// let itemAfter = pollItems[indexAfter]
|
||||
// let cell = pollOptionCollectionViewCell(of: itemAfter)
|
||||
// cell?.pollOptionView.optionTextField.becomeFirstResponder()
|
||||
// }
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1228,9 +1264,9 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega
|
|||
extension ComposeViewController: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate {
|
||||
func composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(_ cell: ComposeStatusPollOptionAppendEntryCollectionViewCell) {
|
||||
viewModel.createNewPollOptionIfPossible()
|
||||
DispatchQueue.main.async {
|
||||
self.markLastPollOptionCollectionViewCellBecomeFirstResponser()
|
||||
}
|
||||
// DispatchQueue.main.async {
|
||||
// self.markLastPollOptionCollectionViewCellBecomeFirstResponser()
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1264,14 +1300,22 @@ extension ComposeViewController: AutoCompleteViewControllerDelegate {
|
|||
}()
|
||||
guard let replacedText = _replacedText else { return }
|
||||
|
||||
guard let textEditorView = textEditorView() else { return }
|
||||
let text = textEditorView.text
|
||||
guard let textEditorView = textEditorView(),
|
||||
let text = textEditorView.textView.text else { return }
|
||||
|
||||
do {
|
||||
try textEditorView.updateByReplacing(range: NSRange(info.toHighlightEndRange, in: text), with: replacedText)
|
||||
viewModel.autoCompleteInfo.value = nil
|
||||
} catch {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete fail %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
|
||||
let range = NSRange(info.toHighlightEndRange, in: text)
|
||||
textEditorView.textStorage.replaceCharacters(in: range, with: replacedText)
|
||||
viewModel.autoCompleteInfo.value = nil
|
||||
|
||||
switch item {
|
||||
case .emoji, .bottomLoader:
|
||||
break
|
||||
default:
|
||||
// set selected range except emoji
|
||||
let newRange = NSRange(location: range.location + (replacedText as NSString).length, length: 0)
|
||||
guard textEditorView.textStorage.length <= newRange.location else { return }
|
||||
textEditorView.textView.selectedRange = newRange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,17 +7,143 @@
|
|||
|
||||
import UIKit
|
||||
import Combine
|
||||
import CoreDataStack
|
||||
import TwitterTextEditor
|
||||
import MastodonSDK
|
||||
import MastodonMeta
|
||||
import MetaTextView
|
||||
|
||||
extension ComposeViewModel {
|
||||
|
||||
func setupDiffableDataSource(
|
||||
tableView: UITableView,
|
||||
metaTextDelegate: MetaTextDelegate,
|
||||
metaTextViewDelegate: UITextViewDelegate,
|
||||
customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel
|
||||
) {
|
||||
let dataSource = UITableViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>(tableView: tableView) { [
|
||||
weak self,
|
||||
weak metaTextDelegate,
|
||||
weak metaTextViewDelegate,
|
||||
weak customEmojiPickerInputViewModel
|
||||
] tableView, indexPath, item in
|
||||
guard let self = self else { return UITableViewCell() }
|
||||
let managedObjectContext = self.context.managedObjectContext
|
||||
|
||||
switch item {
|
||||
case .replyTo(let statusObjectID):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentTableViewCell
|
||||
managedObjectContext.performAndWait {
|
||||
guard let replyTo = managedObjectContext.object(with: statusObjectID) 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
|
||||
let content = MastodonContent(content: status.content, emojis: status.emojiMeta)
|
||||
do {
|
||||
let metaContent = try MastodonMetaContent.convert(document: content)
|
||||
cell.statusView.contentMetaText.configure(content: metaContent)
|
||||
} catch {
|
||||
cell.statusView.contentMetaText.textView.text = " "
|
||||
assertionFailure()
|
||||
}
|
||||
// set date
|
||||
cell.statusView.dateLabel.text = status.createdAt.slowedTimeAgoSinceNow
|
||||
|
||||
cell.framePublisher
|
||||
.assign(to: \.value, on: self.repliedToCellFrame)
|
||||
.store(in: &cell.disposeBag)
|
||||
}
|
||||
return cell
|
||||
case .input(let replyToStatusObjectID, let attribute):
|
||||
let cell = self.composeStatusContentTableViewCell
|
||||
// configure header
|
||||
managedObjectContext.performAndWait {
|
||||
guard let replyToStatusObjectID = replyToStatusObjectID,
|
||||
let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else {
|
||||
cell.statusView.headerContainerView.isHidden = true
|
||||
return
|
||||
}
|
||||
cell.statusView.headerContainerView.isHidden = false
|
||||
cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage)
|
||||
cell.statusView.headerInfoLabel.text = L10n.Scene.Compose.replyingToUser(replyTo.author.displayNameWithFallback)
|
||||
}
|
||||
// configure author
|
||||
ComposeStatusSection.configureStatusContent(cell: cell, attribute: attribute)
|
||||
// bind content warning
|
||||
attribute.isContentWarningComposing
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak cell, weak tableView] isContentWarningComposing in
|
||||
guard let cell = cell else { return }
|
||||
guard let tableView = tableView else { return }
|
||||
// self size input cell
|
||||
//tableView.
|
||||
cell.statusContentWarningEditorView.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 { [weak tableView] text in
|
||||
guard let tableView = tableView else { return }
|
||||
// self size input cell
|
||||
UIView.performWithoutAnimation {
|
||||
tableView.beginUpdates()
|
||||
tableView.endUpdates()
|
||||
}
|
||||
// bind input data
|
||||
attribute.contentWarningContent.value = text
|
||||
}
|
||||
.store(in: &cell.disposeBag)
|
||||
// configure custom emoji picker
|
||||
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.metaText.textView, disposeBag: &cell.disposeBag)
|
||||
ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplaceableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag)
|
||||
// setup delegate
|
||||
cell.metaText.delegate = metaTextDelegate
|
||||
cell.metaText.textView.delegate = metaTextViewDelegate
|
||||
|
||||
return cell
|
||||
case .attachment(let attachmentService):
|
||||
return UITableViewCell()
|
||||
case .pollOption, .pollOptionAppendEntry, .pollExpiresOption:
|
||||
return UITableViewCell()
|
||||
}
|
||||
}
|
||||
self.dataSource = dataSource
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<ComposeStatusSection, ComposeStatusItem>()
|
||||
snapshot.appendSections([.repliedTo, .status, .attachment, .poll])
|
||||
switch composeKind {
|
||||
case .reply(let statusObjectID):
|
||||
snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo)
|
||||
snapshot.appendItems([.input(replyToStatusObjectID: statusObjectID, attribute: composeStatusAttribute)], toSection: .repliedTo)
|
||||
case .hashtag, .mention, .post:
|
||||
snapshot.appendItems([.input(replyToStatusObjectID: nil, attribute: composeStatusAttribute)], toSection: .status)
|
||||
}
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
|
||||
func setupDiffableDataSource(
|
||||
for collectionView: UICollectionView,
|
||||
dependency: NeedsDependency,
|
||||
customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel,
|
||||
textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate,
|
||||
textEditorViewChangeObserver: TextEditorViewChangeObserver,
|
||||
metaTextDelegate: MetaTextDelegate,
|
||||
metaTextViewDelegate: UITextViewDelegate,
|
||||
composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate,
|
||||
composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate,
|
||||
composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate,
|
||||
|
@ -30,8 +156,8 @@ extension ComposeViewModel {
|
|||
composeKind: composeKind,
|
||||
repliedToCellFrameSubscriber: repliedToCellFrame,
|
||||
customEmojiPickerInputViewModel: customEmojiPickerInputViewModel,
|
||||
textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate,
|
||||
textEditorViewChangeObserver: textEditorViewChangeObserver,
|
||||
metaTextDelegate: metaTextDelegate,
|
||||
metaTextViewDelegate: metaTextViewDelegate,
|
||||
composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate,
|
||||
composeStatusPollOptionCollectionViewCellDelegate: composeStatusPollOptionCollectionViewCellDelegate,
|
||||
composeStatusNewPollOptionCollectionViewCellDelegate: composeStatusNewPollOptionCollectionViewCellDelegate,
|
||||
|
|
|
@ -35,6 +35,8 @@ final class ComposeViewModel {
|
|||
let autoCompleteInfo = CurrentValueSubject<ComposeViewController.AutoCompleteInfo?, Never>(nil)
|
||||
|
||||
// output
|
||||
let composeStatusContentTableViewCell = ComposeStatusContentTableViewCell()
|
||||
var dataSource: UITableViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>!
|
||||
var diffableDataSource: UICollectionViewDiffableDataSource<ComposeStatusSection, ComposeStatusItem>!
|
||||
var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource<CustomEmojiPickerSection, CustomEmojiPickerItem>!
|
||||
private(set) lazy var publishStateMachine: GKStateMachine = {
|
||||
|
@ -61,7 +63,7 @@ final class ComposeViewModel {
|
|||
let characterCount = CurrentValueSubject<Int, Never>(0)
|
||||
let collectionViewState = CurrentValueSubject<CollectionViewState, Never>(.fold)
|
||||
|
||||
// for hashtag: "#<hashag> "
|
||||
// for hashtag: "#<hashtag> "
|
||||
// for mention: "@<mention> "
|
||||
private(set) var preInsertedContent: String?
|
||||
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
//
|
||||
// ComposeRepliedToStatusContentTableViewCell.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-6-28.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
final class ComposeRepliedToStatusContentTableViewCell: UITableViewCell {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let statusView = StatusView()
|
||||
|
||||
let framePublisher = PassthroughSubject<CGRect, Never>()
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
statusView.updateContentWarningDisplay(isHidden: true, animated: false)
|
||||
disposeBag.removeAll()
|
||||
}
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
framePublisher.send(bounds)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ComposeRepliedToStatusContentTableViewCell {
|
||||
|
||||
private func _init() {
|
||||
backgroundColor = .clear
|
||||
|
||||
statusView.actionToolbarContainer.isHidden = true
|
||||
statusView.revealContentWarningButton.isHidden = true
|
||||
|
||||
statusView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(statusView)
|
||||
NSLayoutConstraint.activate([
|
||||
statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20).identifier("statusView.top to ComposeRepliedToStatusContentCollectionViewCell.contentView.top"),
|
||||
statusView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
|
||||
contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor),
|
||||
contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10).identifier("ComposeRepliedToStatusContentCollectionViewCell.contentView.bottom to statusView.bottom"),
|
||||
])
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
//
|
||||
// ComposeStatusContentTableViewCell.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-6-28.
|
||||
//
|
||||
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
import Combine
|
||||
import MetaTextView
|
||||
|
||||
final class ComposeStatusContentTableViewCell: UITableViewCell {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let statusView = StatusView()
|
||||
|
||||
let statusContentWarningEditorView = StatusContentWarningEditorView()
|
||||
|
||||
let textEditorViewContainerView = UIView()
|
||||
|
||||
static let metaTextViewTag: Int = 333
|
||||
let metaText: MetaText = {
|
||||
let metaText = MetaText()
|
||||
metaText.textView.tag = ComposeStatusContentCollectionViewCell.metaTextViewTag
|
||||
metaText.textView.backgroundColor = .clear
|
||||
metaText.textView.isScrollEnabled = false
|
||||
metaText.textView.keyboardType = .twitter
|
||||
metaText.textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular))
|
||||
metaText.textView.attributedPlaceholder = {
|
||||
var attributes = metaText.textAttributes
|
||||
attributes[.foregroundColor] = Asset.Colors.Label.secondary.color
|
||||
return NSAttributedString(
|
||||
string: L10n.Scene.Compose.contentInputPlaceholder,
|
||||
attributes: attributes
|
||||
)
|
||||
}()
|
||||
return metaText
|
||||
}()
|
||||
|
||||
// output
|
||||
let contentWarningContent = PassthroughSubject<String, Never>()
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
metaText.delegate = nil
|
||||
metaText.textView.delegate = nil
|
||||
}
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ComposeStatusContentTableViewCell {
|
||||
|
||||
private func _init() {
|
||||
// selectionStyle = .none
|
||||
layer.zPosition = 999
|
||||
preservesSuperviewLayoutMargins = true
|
||||
|
||||
let containerStackView = UIStackView()
|
||||
containerStackView.axis = .vertical
|
||||
containerStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(containerStackView)
|
||||
NSLayoutConstraint.activate([
|
||||
containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||
containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
])
|
||||
containerStackView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
containerStackView.addArrangedSubview(statusContentWarningEditorView)
|
||||
statusContentWarningEditorView.setContentHuggingPriority(.required - 1, for: .vertical)
|
||||
|
||||
let statusContainerView = UIView()
|
||||
statusContainerView.preservesSuperviewLayoutMargins = true
|
||||
containerStackView.addArrangedSubview(statusContainerView)
|
||||
statusView.translatesAutoresizingMaskIntoConstraints = false
|
||||
statusContainerView.addSubview(statusView)
|
||||
NSLayoutConstraint.activate([
|
||||
statusView.topAnchor.constraint(equalTo: statusContainerView.topAnchor, constant: 20),
|
||||
statusView.leadingAnchor.constraint(equalTo: statusContainerView.layoutMarginsGuide.leadingAnchor),
|
||||
statusView.trailingAnchor.constraint(equalTo: statusContainerView.layoutMarginsGuide.trailingAnchor),
|
||||
statusView.bottomAnchor.constraint(equalTo: statusContainerView.bottomAnchor),
|
||||
])
|
||||
|
||||
containerStackView.addArrangedSubview(textEditorViewContainerView)
|
||||
metaText.textView.translatesAutoresizingMaskIntoConstraints = false
|
||||
textEditorViewContainerView.addSubview(metaText.textView)
|
||||
NSLayoutConstraint.activate([
|
||||
metaText.textView.topAnchor.constraint(equalTo: textEditorViewContainerView.topAnchor),
|
||||
metaText.textView.leadingAnchor.constraint(equalTo: textEditorViewContainerView.layoutMarginsGuide.leadingAnchor),
|
||||
metaText.textView.trailingAnchor.constraint(equalTo: textEditorViewContainerView.layoutMarginsGuide.trailingAnchor),
|
||||
metaText.textView.bottomAnchor.constraint(equalTo: textEditorViewContainerView.bottomAnchor),
|
||||
metaText.textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 88).priority(.defaultHigh),
|
||||
])
|
||||
statusContentWarningEditorView.textView.delegate = self
|
||||
|
||||
statusContentWarningEditorView.isHidden = true
|
||||
statusView.statusContainerStackView.isHidden = true
|
||||
statusView.actionToolbarContainer.isHidden = true
|
||||
statusView.revealContentWarningButton.isHidden = true
|
||||
|
||||
statusView.contentMetaText.textView.delegate = self
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UITextViewDelegate
|
||||
extension ComposeStatusContentTableViewCell: UITextViewDelegate {
|
||||
|
||||
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
|
||||
switch textView {
|
||||
case statusView.contentMetaText.textView:
|
||||
return false
|
||||
case statusContentWarningEditorView.textView:
|
||||
// disable input line break
|
||||
guard text != "\n" else { return false }
|
||||
return true
|
||||
default:
|
||||
assertionFailure()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textView.text)
|
||||
guard textView === statusContentWarningEditorView.textView else { return }
|
||||
// replace line break with space
|
||||
textView.text = textView.text.replacingOccurrences(of: "\n", with: " ")
|
||||
contentWarningContent.send(textView.text)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
//
|
||||
// ComposeTableView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-6-28.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class ComposeTableView: UITableView {
|
||||
|
||||
weak var autoCompleteViewController: AutoCompleteViewController?
|
||||
|
||||
// adjust hitTest for auto-complete
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
guard let autoCompleteViewController = autoCompleteViewController else {
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
|
||||
let thePoint = convert(point, to: autoCompleteViewController.view)
|
||||
if let hitView = autoCompleteViewController.view.hitTest(thePoint, with: event) {
|
||||
return hitView
|
||||
} else {
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -9,18 +9,11 @@ import UIKit
|
|||
|
||||
final class StatusContentWarningEditorView: UIView {
|
||||
|
||||
let containerView: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
|
||||
return view
|
||||
}()
|
||||
|
||||
// due to section following readable inset. We overlap the bleeding to make backgorund fill
|
||||
// due to section following readable inset. We overlap the bleeding to make background fill
|
||||
// default hidden
|
||||
let containerBackgroundView: UIView = {
|
||||
let view = UIView()
|
||||
view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color
|
||||
view.isHidden = true
|
||||
return view
|
||||
}()
|
||||
|
||||
|
@ -55,44 +48,38 @@ final class StatusContentWarningEditorView: UIView {
|
|||
|
||||
extension StatusContentWarningEditorView {
|
||||
private func _init() {
|
||||
let contentWarningStackView = UIStackView()
|
||||
contentWarningStackView.axis = .horizontal
|
||||
contentWarningStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(contentWarningStackView)
|
||||
NSLayoutConstraint.activate([
|
||||
contentWarningStackView.topAnchor.constraint(equalTo: topAnchor),
|
||||
contentWarningStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
contentWarningStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
contentWarningStackView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
contentWarningStackView.addArrangedSubview(containerView)
|
||||
|
||||
containerBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(containerBackgroundView)
|
||||
addSubview(containerBackgroundView)
|
||||
NSLayoutConstraint.activate([
|
||||
containerBackgroundView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
containerBackgroundView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: -1024),
|
||||
containerBackgroundView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 1024),
|
||||
containerBackgroundView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
||||
containerBackgroundView.topAnchor.constraint(equalTo: topAnchor),
|
||||
containerBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -1024),
|
||||
containerBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 1024),
|
||||
containerBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
|
||||
iconImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(iconImageView)
|
||||
addSubview(iconImageView)
|
||||
NSLayoutConstraint.activate([
|
||||
iconImageView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
|
||||
iconImageView.leadingAnchor.constraint(equalTo: containerView.readableContentGuide.leadingAnchor),
|
||||
iconImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
iconImageView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
|
||||
iconImageView.widthAnchor.constraint(equalToConstant: StatusView.avatarImageSize.width).priority(.defaultHigh), // center alignment to avatar
|
||||
])
|
||||
iconImageView.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
iconImageView.setContentHuggingPriority(.required - 2, for: .horizontal)
|
||||
|
||||
textView.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(textView)
|
||||
addSubview(textView)
|
||||
NSLayoutConstraint.activate([
|
||||
textView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 6),
|
||||
textView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: StatusView.avatarToLabelSpacing - 4), // align to name label. minus magic 4pt to remove addtion inset
|
||||
textView.trailingAnchor.constraint(equalTo: containerView.readableContentGuide.trailingAnchor),
|
||||
containerView.bottomAnchor.constraint(equalTo: textView.bottomAnchor, constant: 6),
|
||||
textView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
textView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: 6).priority(.required - 1),
|
||||
textView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: StatusView.avatarToLabelSpacing - 4), // align to name label. minus magic 4pt to remove addition inset
|
||||
textView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
|
||||
bottomAnchor.constraint(greaterThanOrEqualTo: textView.bottomAnchor, constant: 6).priority(.required - 1),
|
||||
//textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh),
|
||||
])
|
||||
|
||||
textView.setContentHuggingPriority(.required - 1, for: .vertical)
|
||||
textView.setContentCompressionResistancePriority(.required - 1, for: .vertical)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,8 @@ import Combine
|
|||
import Foundation
|
||||
import UIKit
|
||||
import ActiveLabel
|
||||
import MetaTextView
|
||||
import Meta
|
||||
|
||||
final class NotificationStatusTableViewCell: UITableViewCell, StatusCell {
|
||||
static let actionImageBorderWidth: CGFloat = 2
|
||||
|
@ -256,6 +258,10 @@ extension NotificationStatusTableViewCell: StatusViewDelegate {
|
|||
// do nothing
|
||||
}
|
||||
|
||||
func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension NotificationStatusTableViewCell {
|
||||
|
|
|
@ -12,6 +12,8 @@ import Combine
|
|||
import CoreData
|
||||
import CoreDataStack
|
||||
import ActiveLabel
|
||||
import Meta
|
||||
import MetaTextView
|
||||
|
||||
final class ReportedStatusTableViewCell: UITableViewCell, StatusCell {
|
||||
|
||||
|
@ -203,4 +205,8 @@ extension ReportedStatusTableViewCell: StatusViewDelegate {
|
|||
|
||||
func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
|
||||
}
|
||||
|
||||
func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) {
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -12,6 +12,8 @@ import AVKit
|
|||
import ActiveLabel
|
||||
import AlamofireImage
|
||||
import FLAnimatedImage
|
||||
import MetaTextView
|
||||
import Meta
|
||||
|
||||
// TODO:
|
||||
// import LinkPresentation
|
||||
|
@ -24,10 +26,13 @@ protocol StatusViewDelegate: AnyObject {
|
|||
func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||
func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton)
|
||||
func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity)
|
||||
func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta)
|
||||
}
|
||||
|
||||
final class StatusView: UIView {
|
||||
|
||||
let logger = Logger(subsystem: "StatusView", category: "logic")
|
||||
|
||||
var statusPollTableViewHeightObservation: NSKeyValueObservation?
|
||||
var pollCountdownSubscription: AnyCancellable?
|
||||
|
||||
|
@ -78,6 +83,7 @@ final class StatusView: UIView {
|
|||
let headerInfoLabel: ActiveLabel = {
|
||||
let label = ActiveLabel(style: .statusHeader)
|
||||
label.text = "Bob reblogged"
|
||||
label.layer.masksToBounds = false
|
||||
return label
|
||||
}()
|
||||
|
||||
|
@ -201,7 +207,18 @@ final class StatusView: UIView {
|
|||
return actionToolbarContainer
|
||||
}()
|
||||
|
||||
let activeTextLabel = ActiveLabel(style: .default)
|
||||
//let activeTextLabel = ActiveLabel(style: .default)
|
||||
let contentMetaText: MetaText = {
|
||||
let metaText = MetaText()
|
||||
metaText.textView.backgroundColor = .clear
|
||||
metaText.textView.isEditable = false
|
||||
metaText.textView.isSelectable = false
|
||||
metaText.textView.isScrollEnabled = false
|
||||
metaText.textView.textContainer.lineFragmentPadding = 0
|
||||
metaText.textView.textContainerInset = .zero
|
||||
metaText.textView.layer.masksToBounds = false
|
||||
return metaText
|
||||
}()
|
||||
|
||||
private let headerInfoLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer
|
||||
|
||||
|
@ -261,6 +278,9 @@ extension StatusView {
|
|||
headerContainerView.bottomAnchor.constraint(equalTo: headerContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh),
|
||||
])
|
||||
containerStackView.addArrangedSubview(headerContainerView)
|
||||
defer {
|
||||
containerStackView.bringSubviewToFront(headerContainerView)
|
||||
}
|
||||
|
||||
// author container: [avatar | author meta container | reveal button]
|
||||
let authorContainerStackView = UIStackView()
|
||||
|
@ -360,8 +380,8 @@ extension StatusView {
|
|||
}
|
||||
|
||||
// status
|
||||
statusContainerStackView.addArrangedSubview(activeTextLabel)
|
||||
activeTextLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
|
||||
statusContainerStackView.addArrangedSubview(contentMetaText.textView)
|
||||
contentMetaText.textView.setContentCompressionResistancePriority(.required - 1, for: .vertical)
|
||||
|
||||
// TODO:
|
||||
// link preview
|
||||
|
@ -424,7 +444,8 @@ extension StatusView {
|
|||
avatarStackedContainerButton.isHidden = true
|
||||
contentWarningOverlayView.isHidden = true
|
||||
|
||||
activeTextLabel.delegate = self
|
||||
contentMetaText.textView.delegate = self
|
||||
contentMetaText.textView.linkDelegate = self
|
||||
playerContainerView.delegate = self
|
||||
contentWarningOverlayView.delegate = self
|
||||
|
||||
|
@ -515,6 +536,34 @@ extension StatusView {
|
|||
|
||||
}
|
||||
|
||||
// MARK: - MetaTextViewDelegate
|
||||
extension StatusView: MetaTextViewDelegate {
|
||||
func metaTextView(_ metaTextView: MetaTextView, didSelectLink link: URL) {
|
||||
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
|
||||
switch metaTextView {
|
||||
case contentMetaText.textView:
|
||||
guard let meta = Meta(url: link) else { return }
|
||||
delegate?.statusView(self, metaText: contentMetaText, didSelectMeta: meta)
|
||||
default:
|
||||
assertionFailure()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITextViewDelegate
|
||||
extension StatusView: UITextViewDelegate {
|
||||
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
|
||||
switch textView {
|
||||
case contentMetaText.textView:
|
||||
return false
|
||||
default:
|
||||
assertionFailure()
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ActiveLabelDelegate
|
||||
extension StatusView: ActiveLabelDelegate {
|
||||
func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) {
|
||||
|
|
|
@ -12,6 +12,8 @@ import Combine
|
|||
import CoreData
|
||||
import CoreDataStack
|
||||
import ActiveLabel
|
||||
import Meta
|
||||
import MetaTextView
|
||||
|
||||
protocol StatusTableViewCellDelegate: AnyObject {
|
||||
var context: AppContext! { get }
|
||||
|
@ -26,6 +28,7 @@ protocol StatusTableViewCellDelegate: AnyObject {
|
|||
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton)
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity)
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta)
|
||||
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView)
|
||||
func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int)
|
||||
|
@ -71,6 +74,7 @@ final class StatusTableViewCell: UITableViewCell, StatusCell {
|
|||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
selectionStyle = .default
|
||||
statusView.contentMetaText.textView.isSelectable = false
|
||||
statusView.updateContentWarningDisplay(isHidden: true, animated: false)
|
||||
statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true
|
||||
statusView.pollTableView.dataSource = nil
|
||||
|
@ -301,6 +305,10 @@ extension StatusTableViewCell: StatusViewDelegate {
|
|||
delegate?.statusTableViewCell(self, statusView: statusView, activeLabel: activeLabel, didSelectActiveEntity: entity)
|
||||
}
|
||||
|
||||
func statusView(_ statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta) {
|
||||
delegate?.statusTableViewCell(self, statusView: statusView, metaText: metaText, didSelectMeta: meta)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - MosaicImageViewDelegate
|
||||
|
|
|
@ -33,6 +33,7 @@ extension EmojiService {
|
|||
}()
|
||||
let emojis = CurrentValueSubject<[Mastodon.Entity.Emoji], Never>([])
|
||||
let emojiDict = CurrentValueSubject<[String: [Mastodon.Entity.Emoji]], Never>([:])
|
||||
let emojiMapping = CurrentValueSubject<[String: String], Never>([:])
|
||||
let emojiTrie = CurrentValueSubject<Trie<Character>?, Never>(nil)
|
||||
|
||||
private var learnedEmoji: Set<String> = Set()
|
||||
|
@ -46,6 +47,18 @@ extension EmojiService {
|
|||
.assign(to: \.value, on: emojiDict)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
emojiDict
|
||||
.map { dict in
|
||||
var mapping: [String: String] = [:]
|
||||
for (key, values) in dict {
|
||||
guard let emoji = values.first else { continue }
|
||||
mapping[key] = emoji.url
|
||||
}
|
||||
return mapping
|
||||
}
|
||||
.assign(to: \.value, on: emojiMapping)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
emojis
|
||||
.map { emojis -> Trie<Character>? in
|
||||
guard !emojis.isEmpty else { return nil }
|
||||
|
|
|
@ -60,7 +60,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
|
||||
extension AppDelegate {
|
||||
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
|
||||
#if DEBUG
|
||||
return .all
|
||||
#else
|
||||
return UIDevice.current.userInterfaceIdiom == .phone ? .portrait : .all
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue