Compare commits

...

13 Commits

Author SHA1 Message Date
Shadowfacts fe1db72f19
Fix save draft sheet showing even when draft had no content 2020-09-07 17:15:18 -04:00
Shadowfacts b4ddb8f533
Fix safe area on Compose screen not including keyboard on iOS 13 2020-09-07 17:05:50 -04:00
Shadowfacts 9a4ddfea3f
Fix Compose reply scroll effect not working on iOS 13 2020-09-07 16:56:06 -04:00
Shadowfacts dd8a196630
Show custom emoji in display names on Compose screen 2020-09-07 15:22:06 -04:00
Shadowfacts 3da7aacb35
Fix visiblity context menu in main text view accessory not updating 2020-09-07 14:46:17 -04:00
Shadowfacts 39c8162931
Prevent attempting to add an attachment when the possibility would be
invalid
2020-09-07 14:44:56 -04:00
Shadowfacts fe95cb9e1a
Replace Draw Something context menu item with dedicated button
Fixes add attachment button not working on iOS 13. Adding a context menu
to a Button inside a List on iOS 13 prevents the button from ever
recognizing taps.
2020-09-07 14:41:31 -04:00
Shadowfacts ec2d510be2
Fix crash when opening Compose screen on iOS 13 2020-09-06 23:27:43 -04:00
Shadowfacts 262aadf807
Fix very bad performance when laying out Compose reply view
Using a non-scrolling UITextView wrapped in SwiftUI combined with the
old hack of fixing its layout by passing the view controller's width
down to the wrapped view caused very slow layouts, resulting in
significant lag when typing into the main text view of the compose
screen.
2020-09-06 22:47:02 -04:00
Shadowfacts 9dce94c014
Fix acounts not updating locally
Fix reblogged statuses potentially not updating
2020-09-06 16:03:03 -04:00
Shadowfacts d008b882cb
Use context menu for visibility on iOS 14 2020-08-31 23:07:41 -04:00
Shadowfacts 3d13df87f0
Add pointer interaction to main status favorites/reblogs buttons 2020-08-31 21:40:18 -04:00
Shadowfacts f0582739cc
Re-add Compose button to Profile screen
Add menu with Direct Message option
2020-08-31 21:39:36 -04:00
14 changed files with 312 additions and 113 deletions

View File

@ -227,6 +227,7 @@
D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */; }; D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */; };
D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */; }; D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */; };
D6B053AE23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */; }; D6B053AE23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */; };
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */; };
D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; }; D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; };
D6BC874521961F73006163F1 /* Gifu.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BC874421961F73006163F1 /* Gifu.framework */; }; D6BC874521961F73006163F1 /* Gifu.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BC874421961F73006163F1 /* Gifu.framework */; };
D6BC874621961F73006163F1 /* Gifu.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D6BC874421961F73006163F1 /* Gifu.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D6BC874621961F73006163F1 /* Gifu.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D6BC874421961F73006163F1 /* Gifu.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
@ -547,6 +548,7 @@
D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCollectionViewCell.swift; sourceTree = "<group>"; }; D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCollectionViewCell.swift; sourceTree = "<group>"; };
D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AssetCollectionViewCell.xib; sourceTree = "<group>"; }; D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AssetCollectionViewCell.xib; sourceTree = "<group>"; };
D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerSheetContainerViewController.swift; sourceTree = "<group>"; }; D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerSheetContainerViewController.swift; sourceTree = "<group>"; };
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameLabel.swift; sourceTree = "<group>"; };
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = "<group>"; }; D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = "<group>"; };
D6BC874421961F73006163F1 /* Gifu.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Gifu.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D6BC874421961F73006163F1 /* Gifu.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Gifu.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTableViewController.swift; sourceTree = "<group>"; }; D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTableViewController.swift; sourceTree = "<group>"; };
@ -1251,6 +1253,7 @@
D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */, D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */,
D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */, D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */,
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */, D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D67C57A721E2649B00C3118B /* Account Detail */, D67C57A721E2649B00C3118B /* Account Detail */,
D67C57B021E28F9400C3118B /* Compose Status Reply */, D67C57B021E28F9400C3118B /* Compose Status Reply */,
D626494023C122C800612E6E /* Asset Picker */, D626494023C122C800612E6E /* Asset Picker */,
@ -1794,6 +1797,7 @@
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */, D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */,
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */, D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */, D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */, D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */,
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */, D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */, D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,

View File

@ -124,9 +124,19 @@ extension StatusMO {
self.uri = status.uri self.uri = status.uri
self.url = status.url self.url = status.url
self.visibility = status.visibility self.visibility = status.visibility
self.account = container.account(for: status.account.id, in: context) ?? AccountMO(apiAccount: status.account, container: container, context: context) if let existing = container.account(for: status.account.id, in: context) {
existing.updateFrom(apiAccount: status.account, container: container)
self.account = existing
} else {
self.account = AccountMO(apiAccount: status.account, container: container, context: context)
}
if let reblog = status.reblog { if let reblog = status.reblog {
self.reblog = container.status(for: reblog.id, in: context) ?? StatusMO(apiStatus: reblog, container: container, context: context) if let existing = container.status(for: reblog.id, in: context) {
existing.updateFrom(apiStatus: reblog, container: container)
self.reblog = existing
} else {
self.reblog = StatusMO(apiStatus: reblog, container: container, context: context)
}
} else { } else {
self.reblog = nil self.reblog = nil
} }

View File

@ -44,45 +44,50 @@ struct ComposeAttachmentsList: View {
} }
Button(action: self.addAttachment) { Button(action: self.addAttachment) {
HStack { if #available(iOS 14.0, *) {
addButtonImage Label("Add photo or video", systemImage: addButtonImageName)
Text("Add image or video") } else {
} HStack {
} Image(systemName: addButtonImageName)
.foregroundColor(.blue) Text("Add photo or video")
.disabled(!canAddAttachment)
.frame(height: cellHeight)
.popover(isPresented: $isShowingAssetPickerPopover, content: self.assetPickerPopover)
.contextMenu {
Button(action: self.createDrawing) {
if #available(iOS 14.0, *) {
Label("Draw Something", systemImage: "hand.draw")
} else {
HStack {
Text("Draw Something")
Image(systemName: "hand.draw")
}
} }
} }
.disabled(!canAddAttachment)
} }
.disabled(!canAddAttachment)
.foregroundColor(.blue)
.frame(height: cellHeight / 2)
.popover(isPresented: $isShowingAssetPickerPopover, content: self.assetPickerPopover)
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
Button(action: self.createDrawing) {
if #available(iOS 14.0, *) {
Label("Draw something", systemImage: "hand.draw")
} else {
HStack(alignment: .lastTextBaseline) {
Image(systemName: "hand.draw")
Text("Draw something")
}
}
}
.disabled(!canAddAttachment)
.foregroundColor(.blue)
.frame(height: cellHeight / 2)
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
} }
.frame(height: totalListHeight) .frame(height: totalListHeight)
.onAppear(perform: self.didAppear) .onAppear(perform: self.didAppear)
.onReceive(draft.$attachments, perform: self.attachmentsChanged) .onReceive(draft.$attachments, perform: self.attachmentsChanged)
} }
private var addButtonImage: Image { private var addButtonImageName: String {
let name: String
switch colorScheme { switch colorScheme {
case .dark: case .dark:
name = "photo.fill" return "photo.fill"
case .light: case .light:
name = "photo" return "photo"
@unknown default: @unknown default:
name = "photo" return "photo"
} }
return Image(systemName: name)
} }
private var canAddAttachment: Bool { private var canAddAttachment: Bool {
@ -90,15 +95,14 @@ struct ComposeAttachmentsList: View {
case .pleroma: case .pleroma:
return true return true
case .mastodon: case .mastodon:
// todo: this technically allows invalid image/video combinations return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image }
return draft.attachments.count < 4
} }
} }
private var totalListHeight: CGFloat { private var totalListHeight: CGFloat {
let totalRowHeights = rowHeights.values.reduce(0, +) let totalRowHeights = rowHeights.values.reduce(0, +)
let totalPadding = CGFloat(draft.attachments.count) * cellPadding let totalPadding = CGFloat(draft.attachments.count) * cellPadding
let addButtonHeight = cellHeight + cellPadding let addButtonHeight = cellHeight + cellPadding * 2
return totalRowHeights + totalPadding + addButtonHeight return totalRowHeights + totalPadding + addButtonHeight
} }

View File

@ -11,24 +11,18 @@ import Combine
struct ComposeContainerView: View { struct ComposeContainerView: View {
let mastodonController: MastodonController let mastodonController: MastodonController
let vcWidthSubject: PassthroughSubject<CGFloat, Never>
@ObservedObject var uiState: ComposeUIState @ObservedObject var uiState: ComposeUIState
init( init(
mastodonController: MastodonController, mastodonController: MastodonController,
vcWidthSubject: PassthroughSubject<CGFloat, Never>,
uiState: ComposeUIState uiState: ComposeUIState
) { ) {
self.mastodonController = mastodonController self.mastodonController = mastodonController
self.vcWidthSubject = vcWidthSubject
self.uiState = uiState self.uiState = uiState
} }
var body: some View { var body: some View {
ComposeView( ComposeView(draft: uiState.draft)
draft: uiState.draft,
vcWidthSubject: vcWidthSubject
)
.environmentObject(mastodonController) .environmentObject(mastodonController)
.environmentObject(uiState) .environmentObject(uiState)
} }

View File

@ -20,8 +20,7 @@ struct ComposeCurrentAccount: View {
HStack(alignment: .top) { HStack(alignment: .top) {
ComposeAvatarImageView(url: account.avatar) ComposeAvatarImageView(url: account.avatar)
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(verbatim: account.displayName) AccountDisplayNameLabel(account: mastodonController.persistentContainer.account(for: account.id)!, fontSize: 20)
.font(.system(size: 20, weight: .semibold))
.lineLimit(1) .lineLimit(1)
Text(verbatim: "@\(account.acct)") Text(verbatim: "@\(account.acct)")

View File

@ -16,14 +16,14 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
let mastodonController: MastodonController let mastodonController: MastodonController
let uiState: ComposeUIState let uiState: ComposeUIState
// storing the width in the UI state and having SwiftUI listen to it via @ObservedObject doesn't work
// it ends up spinning forever
let widthSubject = PassthroughSubject<CGFloat, Never>()
var draft: Draft { uiState.draft } var draft: Draft { uiState.draft }
private var cancellables = [AnyCancellable]() private var cancellables = [AnyCancellable]()
private var keyboardHeight: CGFloat = 0
private var toolbarHeight: CGFloat = 44
private var mainToolbar: UIToolbar! private var mainToolbar: UIToolbar!
private var inputAccessoryToolbar: UIToolbar! private var inputAccessoryToolbar: UIToolbar!
private var visibilityBarButtonItems = [UIBarButtonItem]() private var visibilityBarButtonItems = [UIBarButtonItem]()
@ -42,7 +42,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
// to use as the UIHostingController type parameter // to use as the UIHostingController type parameter
let container = ComposeContainerView( let container = ComposeContainerView(
mastodonController: mastodonController, mastodonController: mastodonController,
vcWidthSubject: widthSubject,
uiState: uiState uiState: uiState
) )
super.init(rootView: container) super.init(rootView: container)
@ -58,7 +57,7 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil)
// add the height of the toolbar itself to the bottom of the safe area so content inside SwiftUI ScrollView doesn't underflow it // add the height of the toolbar itself to the bottom of the safe area so content inside SwiftUI ScrollView doesn't underflow it
additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: 44, right: 0) updateAdditionalSafeAreaInsets()
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self) pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
@ -82,12 +81,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
widthSubject.send(view.bounds.width)
}
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
@ -116,9 +109,15 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
let toolbar = UIToolbar() let toolbar = UIToolbar()
toolbar.translatesAutoresizingMaskIntoConstraints = false toolbar.translatesAutoresizingMaskIntoConstraints = false
let visibilityItem = UIBarButtonItem(image: UIImage(systemName: draft.visibility.imageName), style: .plain, target: self, action: #selector(visibilityButtonPressed)) let visibilityAction: Selector?
visibilityItem.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName) if #available(iOS 14.0, *) {
visibilityAction = nil
} else {
visibilityAction = #selector(visibilityButtonPressed(_:))
}
let visibilityItem = UIBarButtonItem(image: UIImage(systemName: draft.visibility.imageName), style: .plain, target: self, action: visibilityAction)
visibilityBarButtonItems.append(visibilityItem) visibilityBarButtonItems.append(visibilityItem)
visibilityChanged(draft.visibility)
toolbar.items = [ toolbar.items = [
UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed)), UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed)),
@ -129,6 +128,9 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
return toolbar return toolbar
} }
private func updateAdditionalSafeAreaInsets() {
additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: toolbarHeight + keyboardHeight, right: 0)
}
@objc private func keyboardWillShow(_ notification: Foundation.Notification) { @objc private func keyboardWillShow(_ notification: Foundation.Notification) {
keyboardWillShow(accessoryView: inputAccessoryToolbar, notification: notification) keyboardWillShow(accessoryView: inputAccessoryToolbar, notification: notification)
@ -139,6 +141,17 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
accessoryView.alpha = 1 accessoryView.alpha = 1
accessoryView.isHidden = false accessoryView.isHidden = false
// on iOS 14, SwiftUI safe area automatically includes the keyboard
if #available(iOS 14.0, *) {
} else {
let userInfo = notification.userInfo!
let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
// temporarily reset add'l safe area insets so we can access the default inset
additionalSafeAreaInsets = .zero
keyboardHeight = frame.height - view.safeAreaInsets.bottom - accessoryView.frame.height
updateAdditionalSafeAreaInsets()
}
} }
@objc private func keyboardWillHide(_ notification: Foundation.Notification) { @objc private func keyboardWillHide(_ notification: Foundation.Notification) {
@ -171,6 +184,13 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
} completion: { (finished) in } completion: { (finished) in
accessoryView.alpha = 1 accessoryView.alpha = 1
} }
// on iOS 14, SwiftUI safe area automatically includes the keyboard
if #available(iOS 14.0, *) {
} else {
keyboardHeight = 0
updateAdditionalSafeAreaInsets()
}
} }
@objc private func keyboardDidHide(_ notification: Foundation.Notification) { @objc private func keyboardDidHide(_ notification: Foundation.Notification) {
@ -185,6 +205,15 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
for item in visibilityBarButtonItems { for item in visibilityBarButtonItems {
item.image = UIImage(systemName: newVisibility.imageName) item.image = UIImage(systemName: newVisibility.imageName)
item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName) item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName)
if #available(iOS 14.0, *) {
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
let state = visibility == newVisibility ? UIMenuElement.State.on : .off
return UIAction(title: visibility.displayName, image: UIImage(systemName: visibility.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { (_) in
self.draft.visibility = visibility
}
}
item.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements)
}
} }
} }
@ -194,7 +223,8 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
case .pleroma: case .pleroma:
return true return true
case .mastodon: case .mastodon:
// todo: this technically allows invalid video/image combinations guard draft.attachments.allSatisfy({ $0.data.type == .image }) else { return false }
// todo: if providers are videos, this technically allows invalid video/image combinations
return itemProviders.count + draft.attachments.count <= 4 return itemProviders.count + draft.attachments.count <= 4
} }
} }
@ -217,12 +247,15 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
} }
@objc func visibilityButtonPressed(_ sender: UIBarButtonItem) { @objc func visibilityButtonPressed(_ sender: UIBarButtonItem) {
let alertController = UIAlertController(currentVisibility: draft.visibility) { (visibility) in // if #available(iOS 14.0, *) {
guard let visibility = visibility else { return } // } else {
self.draft.visibility = visibility let alertController = UIAlertController(currentVisibility: draft.visibility) { (visibility) in
} guard let visibility = visibility else { return }
alertController.popoverPresentationController?.barButtonItem = sender self.draft.visibility = visibility
present(alertController, animated: true) }
alertController.popoverPresentationController?.barButtonItem = sender
present(alertController, animated: true)
// }
} }
@objc func draftsButtonPresed() { @objc func draftsButtonPresed() {
@ -233,17 +266,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
} }
extension ComposeHostingController {
struct EnvironmentWrappingView<Content: View, EnvironmentObject: ObservableObject>: View {
let content: Content
let environmentObject: EnvironmentObject
var body: some View {
content.environmentObject(environmentObject)
}
}
}
extension ComposeHostingController: ComposeUIStateDelegate { extension ComposeHostingController: ComposeUIStateDelegate {
var assetPickerDelegate: AssetPickerViewControllerDelegate? { self } var assetPickerDelegate: AssetPickerViewControllerDelegate? { self }

View File

@ -12,37 +12,33 @@ struct ComposeReplyContentView: UIViewRepresentable {
typealias UIViewType = ComposeReplyContentTextView typealias UIViewType = ComposeReplyContentTextView
let status: StatusMO let status: StatusMO
let maxWidth: CGFloat
@EnvironmentObject var mastodonController: MastodonController @EnvironmentObject var mastodonController: MastodonController
let heightChanged: (CGFloat) -> Void
func makeUIView(context: Context) -> ComposeReplyContentTextView { func makeUIView(context: Context) -> ComposeReplyContentTextView {
let view = ComposeReplyContentTextView() let view = ComposeReplyContentTextView()
view.overrideMastodonController = mastodonController view.overrideMastodonController = mastodonController
view.setTextFrom(status: status) view.setTextFrom(status: status)
view.isScrollEnabled = false
view.isUserInteractionEnabled = false view.isUserInteractionEnabled = false
view.backgroundColor = .clear view.backgroundColor = .clear
view.maxWidth = maxWidth
return view return view
} }
func updateUIView(_ uiView: ComposeReplyContentTextView, context: Context) { func updateUIView(_ uiView: ComposeReplyContentTextView, context: Context) {
uiView.constraint.constant = maxWidth uiView.heightChanged = heightChanged
} }
} }
class ComposeReplyContentTextView: StatusContentTextView { class ComposeReplyContentTextView: StatusContentTextView {
var heightChanged: ((CGFloat) -> Void)?
var maxWidth: CGFloat! override func layoutSubviews() {
var constraint: NSLayoutConstraint! super.layoutSubviews()
override func didMoveToSuperview() { heightChanged?(contentSize.height)
super.didMoveToSuperview()
translatesAutoresizingMaskIntoConstraints = false
constraint = widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth)
constraint.isActive = true
} }
} }

View File

@ -10,8 +10,10 @@ import SwiftUI
struct ComposeReplyView: View { struct ComposeReplyView: View {
let status: StatusMO let status: StatusMO
let maxWidth: CGFloat
let stackPadding: CGFloat let stackPadding: CGFloat
let outerMinY: CGFloat
@State private var contentHeight: CGFloat?
private let horizSpacing: CGFloat = 8 private let horizSpacing: CGFloat = 8
@ -22,8 +24,7 @@ struct ComposeReplyView: View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack { HStack {
Text(verbatim: status.account.displayName) AccountDisplayNameLabel(account: status.account, fontSize: 17)
.font(.system(size: 17, weight: .semibold))
.lineLimit(1) .lineLimit(1)
.layoutPriority(1) .layoutPriority(1)
@ -35,7 +36,10 @@ struct ComposeReplyView: View {
Spacer() Spacer()
} }
ComposeReplyContentView(status: status, maxWidth: maxWidth - 50 - horizSpacing + 4) ComposeReplyContentView(status: status) { (newHeight) in
self.contentHeight = newHeight
}
.frame(height: contentHeight)
.offset(x: -4, y: -8) .offset(x: -4, y: -8)
.padding(.bottom, -8) .padding(.bottom, -8)
} }
@ -45,8 +49,11 @@ struct ComposeReplyView: View {
} }
private func replyAvatarImage(geometry: GeometryProxy) -> some View { private func replyAvatarImage(geometry: GeometryProxy) -> some View {
let scrollOffset = geometry.frame(in: .named("outer")).minY - stackPadding // using named coordinate spaces produces an incorrect scroll offset on iOS 13,
let offset = min(max(-scrollOffset, 0), geometry.size.height - 50 - 8) // so simply compare the geometry inside and outside the scroll view in the global coordinate space
var scrollOffset = outerMinY - geometry.frame(in: .global).minY
scrollOffset += stackPadding
let offset = min(max(scrollOffset, 0), geometry.size.height - 50 - stackPadding)
return ComposeAvatarImageView(url: status.account.avatar) return ComposeAvatarImageView(url: status.account.avatar)
.offset(x: 0, y: offset) .offset(x: 0, y: offset)
} }

View File

@ -12,10 +12,8 @@ import Combine
struct ComposeView: View { struct ComposeView: View {
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
let vcWidthSubject: PassthroughSubject<CGFloat, Never>
@EnvironmentObject var mastodonController: MastodonController @EnvironmentObject var mastodonController: MastodonController
@State var viewControllerWidth: CGFloat = 0
@EnvironmentObject var uiState: ComposeUIState @EnvironmentObject var uiState: ComposeUIState
@State var isPosting = false @State var isPosting = false
@State var postProgress: Double = 0 @State var postProgress: Double = 0
@ -25,12 +23,8 @@ struct ComposeView: View {
private let stackPadding: CGFloat = 8 private let stackPadding: CGFloat = 8
init( init(draft: Draft) {
draft: Draft,
vcWidthSubject: PassthroughSubject<CGFloat, Never>
) {
self.draft = draft self.draft = draft
self.vcWidthSubject = vcWidthSubject
} }
var charactersRemaining: Int { var charactersRemaining: Int {
@ -63,17 +57,17 @@ struct ComposeView: View {
var mostOfTheBody: some View { var mostOfTheBody: some View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
ScrollView(.vertical) { GeometryReader { (outer) in
mainStack ScrollView(.vertical) {
mainStack(outerMinY: outer.frame(in: .global).minY)
}
} }
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149 // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
WrappedProgressView(value: postProgress, total: postTotalProgress) WrappedProgressView(value: postProgress, total: postTotalProgress)
} }
.coordinateSpace(name: "outer")
.onAppear(perform: self.didAppear) .onAppear(perform: self.didAppear)
.navigationBarTitle("Compose") .navigationBarTitle("Compose")
.onReceive(vcWidthSubject) { self.viewControllerWidth = $0 }
.actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet) .actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet)
.alert(isPresented: $isShowingPostErrorAlert) { .alert(isPresented: $isShowingPostErrorAlert) {
Alert( Alert(
@ -84,14 +78,14 @@ struct ComposeView: View {
} }
} }
var mainStack: some View { func mainStack(outerMinY: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
if let id = draft.inReplyToID, if let id = draft.inReplyToID,
let status = mastodonController.persistentContainer.status(for: id) { let status = mastodonController.persistentContainer.status(for: id) {
ComposeReplyView( ComposeReplyView(
status: status, status: status,
maxWidth: viewControllerWidth - (2 * stackPadding), stackPadding: stackPadding,
stackPadding: stackPadding outerMinY: outerMinY
) )
} }
@ -151,7 +145,13 @@ struct ComposeView: View {
// draft is already stored in drafts manager, drafts manager is saved by ComposeHostingController.viewWillDisappear // draft is already stored in drafts manager, drafts manager is saved by ComposeHostingController.viewWillDisappear
uiState.delegate?.dismissCompose() uiState.delegate?.dismissCompose()
} else { } else {
uiState.isShowingSaveDraftSheet = true // if the draft doesn't have content, it doesn't need to be saved
if draft.hasContent {
uiState.isShowingSaveDraftSheet = true
} else {
DraftsManager.shared.remove(draft)
uiState.delegate?.dismissCompose()
}
} }
} }

View File

@ -64,7 +64,14 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
textView.textContainer.lineBreakMode = .byWordWrapping textView.textContainer.lineBreakMode = .byWordWrapping
context.coordinator.textView = textView context.coordinator.textView = textView
let visibilityButton = UIBarButtonItem(image: UIImage(systemName: visibility.imageName), style: .plain, target: nil, action: #selector(ComposeHostingController.visibilityButtonPressed(_:))) let visibilityAction: Selector?
if #available(iOS 14.0, *) {
visibilityAction = nil
} else {
visibilityAction = #selector(ComposeHostingController.visibilityButtonPressed(_:))
}
let visibilityButton = UIBarButtonItem(image: UIImage(systemName: visibility.imageName), style: .plain, target: nil, action: visibilityAction)
updateVisibilityMenu(visibilityButton)
let toolbar = UIToolbar() let toolbar = UIToolbar()
toolbar.translatesAutoresizingMaskIntoConstraints = false toolbar.translatesAutoresizingMaskIntoConstraints = false
toolbar.items = [ toolbar.items = [
@ -118,16 +125,33 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
return formatButtons return formatButtons
} }
private func updateVisibilityMenu(_ visibilityButton: UIBarButtonItem) {
if #available(iOS 14.0, *) {
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
let state = visibility == self.visibility ? UIMenuElement.State.on : .off
return UIAction(title: visibility.displayName, image: UIImage(systemName: visibility.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { (_) in
self.uiState.draft.visibility = visibility
}
}
visibilityButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements)
}
}
func updateUIView(_ uiView: UITextView, context: Context) { func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text uiView.text = text
visibilityButton?.image = UIImage(systemName: visibility.imageName) if let visibilityButton = visibilityButton {
visibilityButton.image = UIImage(systemName: visibility.imageName)
updateVisibilityMenu(visibilityButton)
}
context.coordinator.text = $text context.coordinator.text = $text
context.coordinator.didChange = textDidChange context.coordinator.didChange = textDidChange
context.coordinator.uiState = uiState context.coordinator.uiState = uiState
if becomeFirstResponder { if becomeFirstResponder {
uiView.becomeFirstResponder()
DispatchQueue.main.async { DispatchQueue.main.async {
// calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13
uiView.becomeFirstResponder()
// can't update @State vars during the SwiftUI update
becomeFirstResponder = false becomeFirstResponder = false
} }
} }

View File

@ -58,6 +58,16 @@ class ProfileViewController: UIPageViewController {
view.backgroundColor = .systemBackground view.backgroundColor = .systemBackground
let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning))
if #available(iOS 14.0, *) {
composeButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: [
UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
self.composeDirectMentioning()
})
])
}
navigationItem.rightBarButtonItem = composeButton
headerView = ProfileHeaderView.create() headerView = ProfileHeaderView.create()
headerView.delegate = self headerView.delegate = self
@ -162,6 +172,22 @@ class ProfileViewController: UIPageViewController {
} }
} }
// MARK: Interaction
@objc private func composeMentioning() {
if let account = mastodonController.persistentContainer.account(for: accountID) {
compose(mentioningAcct: account.acct)
}
}
private func composeDirectMentioning() {
if let account = mastodonController.persistentContainer.account(for: accountID) {
let draft = mastodonController.createDraft(mentioningAcct: account.acct)
draft.visibility = .direct
compose(editing: draft)
}
}
} }
extension ProfileViewController: TuskerNavigationDelegate { extension ProfileViewController: TuskerNavigationDelegate {

View File

@ -79,8 +79,7 @@ extension TuskerNavigationDelegate {
show(ConversationTableViewController(for: statusID, state: state, mastodonController: apiController), sender: self) show(ConversationTableViewController(for: statusID, state: state, mastodonController: apiController), sender: self)
} }
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) { func compose(editing draft: Draft) {
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)
let compose = ComposeHostingController(draft: draft, mastodonController: apiController) let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
let vc = UINavigationController(rootViewController: compose) let vc = UINavigationController(rootViewController: compose)
@ -88,6 +87,11 @@ extension TuskerNavigationDelegate {
present(vc, animated: true) present(vc, animated: true)
} }
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) {
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)
compose(editing: draft)
}
func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController { func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController {
let vc = LoadingLargeImageViewController(url: url, cache: cache, imageDescription: description) let vc = LoadingLargeImageViewController(url: url, cache: cache, imageDescription: description)
vc.animationSourceView = sourceView vc.animationSourceView = sourceView

View File

@ -0,0 +1,109 @@
//
// AccountDisplayNameLabel.swift
// Tusker
//
// Created by Shadowfacts on 9/7/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
struct AccountDisplayNameLabel: View {
let account: AccountMO
let fontSize: Int
@State var text: Text
@State var emojiRequests = [ImageCache.Request]()
init(account: AccountMO, fontSize: Int) {
self.account = account
self.fontSize = fontSize
self._text = State(initialValue: Text(verbatim: account.displayName))
}
var body: some View {
if #available(iOS 14.0, *) {
text
.font(.system(size: CGFloat(fontSize), weight: .semibold))
.onAppear(perform: self.loadEmojis)
} else {
text
.font(.system(size: CGFloat(fontSize), weight: .semibold))
}
}
// embedding Image inside Text is only available on iOS 14
@available(iOS 14.0, *)
private func loadEmojis() {
let fullRange = NSRange(account.displayName.startIndex..., in: account.displayName)
let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange)
guard !matches.isEmpty else { return }
let emojiImages = CachedDictionary<Image>(name: "AcccountDisplayNameLabel Emoji Images")
let group = DispatchGroup()
for emoji in account.emojis {
guard matches.contains(where: { (match) in
let matchShortcode = (account.displayName as NSString).substring(with: match.range(at: 1))
return emoji.shortcode == matchShortcode
}) else {
continue
}
group.enter()
let request = ImageCache.emojis.get(emoji.url) { (data) in
defer { group.leave() }
guard let data = data, let image = UIImage(data: data) else { return }
let size = CGSize(width: fontSize, height: fontSize)
let renderer = UIGraphicsImageRenderer(size: size)
let resized = renderer.image { (ctx) in
image.draw(in: CGRect(origin: .zero, size: size))
}
emojiImages[emoji.shortcode] = Image(uiImage: resized)
}
if let request = request {
emojiRequests.append(request)
}
}
group.notify(queue: .main) {
var text: Text?
var endIndex = account.displayName.utf16.count
// iterate backwards as to not alter the indices of earlier matches
for match in matches.reversed() {
let shortcode = (account.displayName as NSString).substring(with: match.range(at: 1))
guard let image = emojiImages[shortcode] else { continue }
let afterCurrentMatch = (account.displayName as NSString).substring(with: NSRange(location: match.range.upperBound, length: endIndex - match.range.upperBound))
if let subsequent = text {
text = Text(image) + Text(verbatim: afterCurrentMatch) + subsequent
} else {
text = Text(image) + Text(verbatim: afterCurrentMatch)
}
endIndex = match.range.lowerBound
}
let beforeLastMatch = (account.displayName as NSString).substring(to: endIndex)
if let text = text {
self.text = Text(verbatim: beforeLastMatch) + text
} else {
self.text = Text(verbatim: beforeLastMatch)
}
}
}
}
//struct AccountDisplayNameLabel_Previews: PreviewProvider {
// static var previews: some View {
// AccountDisplayNameLabel()
// }
//}

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17147" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17154" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/> <device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17120"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17124"/>
<capability name="Image references" minToolsVersion="12.0"/> <capability name="Image references" minToolsVersion="12.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
@ -113,7 +113,7 @@
<stackView opaque="NO" contentMode="scaleToFill" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="HZv-qj-gi6"> <stackView opaque="NO" contentMode="scaleToFill" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="HZv-qj-gi6">
<rect key="frame" x="0.0" y="188.5" width="142" height="18"/> <rect key="frame" x="0.0" y="188.5" width="142" height="18"/>
<subviews> <subviews>
<button opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="252" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="yyj-Bs-Vjq"> <button opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="252" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="yyj-Bs-Vjq">
<rect key="frame" x="0.0" y="0.0" width="75" height="18"/> <rect key="frame" x="0.0" y="0.0" width="75" height="18"/>
<constraints> <constraints>
<constraint firstAttribute="height" constant="18" id="F9W-LW-swd"/> <constraint firstAttribute="height" constant="18" id="F9W-LW-swd"/>
@ -125,7 +125,7 @@
<action selector="totalFavoritesPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="D3Y-YB-bqP"/> <action selector="totalFavoritesPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="D3Y-YB-bqP"/>
</connections> </connections>
</button> </button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="dem-vG-cPB"> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="dem-vG-cPB">
<rect key="frame" x="83" y="0.0" width="59" height="18"/> <rect key="frame" x="83" y="0.0" width="59" height="18"/>
<constraints> <constraints>
<constraint firstAttribute="height" constant="18" id="k0P-W7-wMF"/> <constraint firstAttribute="height" constant="18" id="k0P-W7-wMF"/>
@ -247,7 +247,7 @@
</view> </view>
</objects> </objects>
<resources> <resources>
<image name="arrowshape.turn.up.left.fill" catalog="system" width="128" height="104"/> <image name="arrowshape.turn.up.left.fill" catalog="system" width="128" height="106"/>
<image name="chevron.down" catalog="system" width="128" height="72"/> <image name="chevron.down" catalog="system" width="128" height="72"/>
<image name="ellipsis" catalog="system" width="128" height="37"/> <image name="ellipsis" catalog="system" width="128" height="37"/>
<image name="globe" catalog="system" width="128" height="121"/> <image name="globe" catalog="system" width="128" height="121"/>