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 */; };
D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */; };
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 */; };
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, ); }; };
@ -547,6 +548,7 @@
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>"; };
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>"; };
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>"; };
@ -1251,6 +1253,7 @@
D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */,
D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */,
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D67C57A721E2649B00C3118B /* Account Detail */,
D67C57B021E28F9400C3118B /* Compose Status Reply */,
D626494023C122C800612E6E /* Asset Picker */,
@ -1794,6 +1797,7 @@
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */,
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
D67C57B421E2910700C3118B /* ComposeStatusReplyView.swift in Sources */,
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,

View File

@ -124,9 +124,19 @@ extension StatusMO {
self.uri = status.uri
self.url = status.url
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 {
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 {
self.reblog = nil
}

View File

@ -44,45 +44,50 @@ struct ComposeAttachmentsList: View {
}
Button(action: self.addAttachment) {
HStack {
addButtonImage
Text("Add image or video")
}
}
.foregroundColor(.blue)
.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")
}
if #available(iOS 14.0, *) {
Label("Add photo or video", systemImage: addButtonImageName)
} else {
HStack {
Image(systemName: addButtonImageName)
Text("Add photo or video")
}
}
.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)
.onAppear(perform: self.didAppear)
.onReceive(draft.$attachments, perform: self.attachmentsChanged)
}
private var addButtonImage: Image {
let name: String
private var addButtonImageName: String {
switch colorScheme {
case .dark:
name = "photo.fill"
return "photo.fill"
case .light:
name = "photo"
return "photo"
@unknown default:
name = "photo"
return "photo"
}
return Image(systemName: name)
}
private var canAddAttachment: Bool {
@ -90,15 +95,14 @@ struct ComposeAttachmentsList: View {
case .pleroma:
return true
case .mastodon:
// todo: this technically allows invalid image/video combinations
return draft.attachments.count < 4
return draft.attachments.count < 4 && draft.attachments.allSatisfy { $0.data.type == .image }
}
}
private var totalListHeight: CGFloat {
let totalRowHeights = rowHeights.values.reduce(0, +)
let totalPadding = CGFloat(draft.attachments.count) * cellPadding
let addButtonHeight = cellHeight + cellPadding
let addButtonHeight = cellHeight + cellPadding * 2
return totalRowHeights + totalPadding + addButtonHeight
}

View File

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

View File

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

View File

@ -16,14 +16,14 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
let mastodonController: MastodonController
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 }
private var cancellables = [AnyCancellable]()
private var keyboardHeight: CGFloat = 0
private var toolbarHeight: CGFloat = 44
private var mainToolbar: UIToolbar!
private var inputAccessoryToolbar: UIToolbar!
private var visibilityBarButtonItems = [UIBarButtonItem]()
@ -42,7 +42,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
// to use as the UIHostingController type parameter
let container = ComposeContainerView(
mastodonController: mastodonController,
vcWidthSubject: widthSubject,
uiState: uiState
)
super.init(rootView: container)
@ -58,7 +57,7 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
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
additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: 44, right: 0)
updateAdditionalSafeAreaInsets()
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
@ -82,12 +81,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
widthSubject.send(view.bounds.width)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
@ -116,9 +109,15 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
let toolbar = UIToolbar()
toolbar.translatesAutoresizingMaskIntoConstraints = false
let visibilityItem = UIBarButtonItem(image: UIImage(systemName: draft.visibility.imageName), style: .plain, target: self, action: #selector(visibilityButtonPressed))
visibilityItem.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName)
let visibilityAction: Selector?
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)
visibilityChanged(draft.visibility)
toolbar.items = [
UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed)),
@ -129,6 +128,9 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
return toolbar
}
private func updateAdditionalSafeAreaInsets() {
additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: toolbarHeight + keyboardHeight, right: 0)
}
@objc private func keyboardWillShow(_ notification: Foundation.Notification) {
keyboardWillShow(accessoryView: inputAccessoryToolbar, notification: notification)
@ -139,6 +141,17 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
accessoryView.alpha = 1
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) {
@ -171,6 +184,13 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
} completion: { (finished) in
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) {
@ -185,6 +205,15 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
for item in visibilityBarButtonItems {
item.image = UIImage(systemName: newVisibility.imageName)
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:
return true
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
}
}
@ -217,12 +247,15 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
}
@objc func visibilityButtonPressed(_ sender: UIBarButtonItem) {
let alertController = UIAlertController(currentVisibility: draft.visibility) { (visibility) in
guard let visibility = visibility else { return }
self.draft.visibility = visibility
}
alertController.popoverPresentationController?.barButtonItem = sender
present(alertController, animated: true)
// if #available(iOS 14.0, *) {
// } else {
let alertController = UIAlertController(currentVisibility: draft.visibility) { (visibility) in
guard let visibility = visibility else { return }
self.draft.visibility = visibility
}
alertController.popoverPresentationController?.barButtonItem = sender
present(alertController, animated: true)
// }
}
@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 {
var assetPickerDelegate: AssetPickerViewControllerDelegate? { self }

View File

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

View File

@ -10,8 +10,10 @@ import SwiftUI
struct ComposeReplyView: View {
let status: StatusMO
let maxWidth: CGFloat
let stackPadding: CGFloat
let outerMinY: CGFloat
@State private var contentHeight: CGFloat?
private let horizSpacing: CGFloat = 8
@ -22,8 +24,7 @@ struct ComposeReplyView: View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text(verbatim: status.account.displayName)
.font(.system(size: 17, weight: .semibold))
AccountDisplayNameLabel(account: status.account, fontSize: 17)
.lineLimit(1)
.layoutPriority(1)
@ -35,7 +36,10 @@ struct ComposeReplyView: View {
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)
.padding(.bottom, -8)
}
@ -45,8 +49,11 @@ struct ComposeReplyView: View {
}
private func replyAvatarImage(geometry: GeometryProxy) -> some View {
let scrollOffset = geometry.frame(in: .named("outer")).minY - stackPadding
let offset = min(max(-scrollOffset, 0), geometry.size.height - 50 - 8)
// using named coordinate spaces produces an incorrect scroll offset on iOS 13,
// 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)
.offset(x: 0, y: offset)
}

View File

@ -12,10 +12,8 @@ import Combine
struct ComposeView: View {
@ObservedObject var draft: Draft
let vcWidthSubject: PassthroughSubject<CGFloat, Never>
@EnvironmentObject var mastodonController: MastodonController
@State var viewControllerWidth: CGFloat = 0
@EnvironmentObject var uiState: ComposeUIState
@State var isPosting = false
@State var postProgress: Double = 0
@ -25,12 +23,8 @@ struct ComposeView: View {
private let stackPadding: CGFloat = 8
init(
draft: Draft,
vcWidthSubject: PassthroughSubject<CGFloat, Never>
) {
init(draft: Draft) {
self.draft = draft
self.vcWidthSubject = vcWidthSubject
}
var charactersRemaining: Int {
@ -63,17 +57,17 @@ struct ComposeView: View {
var mostOfTheBody: some View {
ZStack(alignment: .top) {
ScrollView(.vertical) {
mainStack
GeometryReader { (outer) in
ScrollView(.vertical) {
mainStack(outerMinY: outer.frame(in: .global).minY)
}
}
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
WrappedProgressView(value: postProgress, total: postTotalProgress)
}
.coordinateSpace(name: "outer")
.onAppear(perform: self.didAppear)
.navigationBarTitle("Compose")
.onReceive(vcWidthSubject) { self.viewControllerWidth = $0 }
.actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet)
.alert(isPresented: $isShowingPostErrorAlert) {
Alert(
@ -84,14 +78,14 @@ struct ComposeView: View {
}
}
var mainStack: some View {
func mainStack(outerMinY: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 8) {
if let id = draft.inReplyToID,
let status = mastodonController.persistentContainer.status(for: id) {
ComposeReplyView(
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
uiState.delegate?.dismissCompose()
} 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
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()
toolbar.translatesAutoresizingMaskIntoConstraints = false
toolbar.items = [
@ -118,16 +125,33 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
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) {
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.didChange = textDidChange
context.coordinator.uiState = uiState
if becomeFirstResponder {
uiView.becomeFirstResponder()
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
}
}

View File

@ -58,6 +58,16 @@ class ProfileViewController: UIPageViewController {
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.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 {

View File

@ -79,8 +79,7 @@ extension TuskerNavigationDelegate {
show(ConversationTableViewController(for: statusID, state: state, mastodonController: apiController), sender: self)
}
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) {
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)
func compose(editing draft: Draft) {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
let vc = UINavigationController(rootViewController: compose)
@ -88,6 +87,11 @@ extension TuskerNavigationDelegate {
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 {
let vc = LoadingLargeImageViewController(url: url, cache: cache, imageDescription: description)
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"?>
<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"/>
<dependencies>
<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="Safe area layout guides" minToolsVersion="9.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">
<rect key="frame" x="0.0" y="188.5" width="142" height="18"/>
<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"/>
<constraints>
<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"/>
</connections>
</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"/>
<constraints>
<constraint firstAttribute="height" constant="18" id="k0P-W7-wMF"/>
@ -247,7 +247,7 @@
</view>
</objects>
<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="ellipsis" catalog="system" width="128" height="37"/>
<image name="globe" catalog="system" width="128" height="121"/>