diff --git a/Pachyderm/Client.swift b/Pachyderm/Client.swift index efbf5830..1c50d16f 100644 --- a/Pachyderm/Client.swift +++ b/Pachyderm/Client.swift @@ -335,7 +335,8 @@ public class Client { language: String? = nil, pollOptions: [String]? = nil, pollExpiresIn: Int? = nil, - pollMultiple: Bool? = nil) -> Request { + pollMultiple: Bool? = nil, + localOnly: Bool? = nil) -> Request { return Request(method: .post, path: "/api/v1/statuses", body: ParametersBody([ "status" => text, "content_type" => contentType.mimeType, @@ -346,6 +347,7 @@ public class Client { "language" => language, "poll[expires_in]" => pollExpiresIn, "poll[multiple]" => pollMultiple, + "local_only" => localOnly, ] + "media_ids" => media?.map { $0.id } + "poll[options]" => pollOptions)) } diff --git a/Tusker/Models/Draft.swift b/Tusker/Models/Draft.swift index 9db9d012..3e5ce793 100644 --- a/Tusker/Models/Draft.swift +++ b/Tusker/Models/Draft.swift @@ -21,6 +21,7 @@ class Draft: Codable, ObservableObject { @Published var inReplyToID: String? @Published var visibility: Status.Visibility @Published var poll: Poll? + @Published var localOnly: Bool var initialText: String @@ -49,6 +50,7 @@ class Draft: Codable, ObservableObject { self.inReplyToID = nil self.visibility = Preferences.shared.defaultPostVisibility self.poll = nil + self.localOnly = false self.initialText = "" } @@ -67,6 +69,7 @@ class Draft: Codable, ObservableObject { self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID) self.visibility = try container.decode(Status.Visibility.self, forKey: .visibility) self.poll = try container.decode(Poll.self, forKey: .poll) + self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false self.initialText = try container.decode(String.self, forKey: .initialText) } @@ -85,6 +88,7 @@ class Draft: Codable, ObservableObject { try container.encode(inReplyToID, forKey: .inReplyToID) try container.encode(visibility, forKey: .visibility) try container.encode(poll, forKey: .poll) + try container.encode(localOnly, forKey: .localOnly) try container.encode(initialText, forKey: .initialText) } @@ -109,6 +113,7 @@ extension Draft { case inReplyToID case visibility case poll + case localOnly case initialText } diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index b66d7688..2599263b 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -32,6 +32,7 @@ class ComposeHostingController: UIHostingController { private var mainToolbar: UIToolbar! private var inputAccessoryToolbar: UIToolbar! private var visibilityBarButtonItems = [UIBarButtonItem]() + private var localOnlyItems = [UIBarButtonItem]() override var inputAccessoryView: UIView? { inputAccessoryToolbar } @@ -54,6 +55,7 @@ class ComposeHostingController: UIHostingController { self.uiState.delegate = self // main toolbar is shown at the bottom of the screen, the input accessory is attached to the keyboard while editing + // (except for MainComposeTextView which has its own accessory to add formatting buttons) mainToolbar = createToolbar() inputAccessoryToolbar = createToolbar() @@ -73,6 +75,11 @@ class ComposeHostingController: UIHostingController { .sink(receiveValue: self.visibilityChanged) .store(in: &cancellables) + self.uiState.$draft + .flatMap(\.$localOnly) + .sink(receiveValue: self.localOnlyChanged) + .store(in: &cancellables) + self.uiState.$draft .flatMap(\.objectWillChange) .debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility)) @@ -114,7 +121,7 @@ class ComposeHostingController: UIHostingController { toolbar.translatesAutoresizingMaskIntoConstraints = false toolbar.isAccessibilityElement = true - let visibilityItem = UIBarButtonItem(image: UIImage(systemName: draft.visibility.imageName), style: .plain, target: nil, action: nil) + let visibilityItem = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil) visibilityBarButtonItems.append(visibilityItem) visibilityChanged(draft.visibility) @@ -124,6 +131,14 @@ class ComposeHostingController: UIHostingController { UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPresed)) ] + + if mastodonController.instanceFeatures.localOnlyPosts { + let item = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil) + toolbar.items!.insert(item, at: 2) + localOnlyItems.append(item) + localOnlyChanged(draft.localOnly) + } + return toolbar } @@ -185,11 +200,10 @@ class ComposeHostingController: UIHostingController { private func visibilityChanged(_ newVisibility: Status.Visibility) { for item in visibilityBarButtonItems { item.image = UIImage(systemName: newVisibility.imageName) - item.image!.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) 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 + return UIAction(title: visibility.displayName, image: UIImage(systemName: visibility.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { [unowned self] (_) in self.draft.visibility = visibility } } @@ -197,6 +211,27 @@ class ComposeHostingController: UIHostingController { } } + private func localOnlyChanged(_ localOnly: Bool) { + for item in localOnlyItems { + if localOnly { + item.image = UIImage(named: "link.broken") + item.accessibilityLabel = "Local-only" + } else { + item.image = UIImage(systemName: "link") + item.accessibilityLabel = "Federated" + } + item.menu = UIMenu(children: [ + // todo: iOS 15, action subtitles + UIAction(title: "Local-only", image: UIImage(named: "link.broken"), state: localOnly ? .on : .off) { [unowned self] (_) in + self.draft.localOnly = true + }, + UIAction(title: "Federated", image: UIImage(systemName: "link"), state: localOnly ? .off : .on) { [unowned self] (_) in + self.draft.localOnly = false + }, + ]) + } + } + override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false } switch mastodonController.instance.instanceType { diff --git a/Tusker/Screens/Compose/ComposeView.swift b/Tusker/Screens/Compose/ComposeView.swift index da6f5382..38030040 100644 --- a/Tusker/Screens/Compose/ComposeView.swift +++ b/Tusker/Screens/Compose/ComposeView.swift @@ -219,7 +219,8 @@ struct ComposeView: View { language: nil, pollOptions: draft.poll?.options.map(\.text), pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), - pollMultiple: draft.poll?.multiple) + pollMultiple: draft.poll?.multiple, + localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil) self.mastodonController.run(request) { (response) in switch response { case let .failure(error): diff --git a/Tusker/Screens/Compose/MainComposeTextView.swift b/Tusker/Screens/Compose/MainComposeTextView.swift index 1582c735..a7271030 100644 --- a/Tusker/Screens/Compose/MainComposeTextView.swift +++ b/Tusker/Screens/Compose/MainComposeTextView.swift @@ -56,7 +56,10 @@ struct MainComposeWrappedTextView: UIViewRepresentable { var textDidChange: (UITextView) -> Void @EnvironmentObject var uiState: ComposeUIState + @EnvironmentObject var mastodonController: MastodonController + // todo: should these be part of the coordinator? @State var visibilityButton: UIBarButtonItem? + @State var localOnlyButton: UIBarButtonItem? func makeUIView(context: Context) -> UITextView { let textView = WrappedTextView() @@ -87,6 +90,22 @@ struct MainComposeWrappedTextView: UIViewRepresentable { self.visibilityButton = visibilityButton } + if mastodonController.instanceFeatures.localOnlyPosts { + let image: UIImage + if uiState.draft.localOnly { + image = UIImage(named: "link.broken")! + } else { + image = UIImage(systemName: "link")! + } + let item = UIBarButtonItem(image: image, style: .plain, target: nil, action: nil) + toolbar.items!.insert(item, at: 2) + updateLocalOnlyMenu(item) + + DispatchQueue.main.async { + self.localOnlyButton = item + } + } + NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) NotificationCenter.default.addObserver(context.coordinator, selector: #selector(Coordinator.keyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil) @@ -134,6 +153,17 @@ struct MainComposeWrappedTextView: UIViewRepresentable { visibilityButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements) } + private func updateLocalOnlyMenu(_ localOnlyButton: UIBarButtonItem) { + localOnlyButton.menu = UIMenu(children: [ + UIAction(title: "Local-only", image: UIImage(named: "link.broken"), state: uiState.draft.localOnly ? .on : .off) { (_) in + self.uiState.draft.localOnly = true + }, + UIAction(title: "Federated", image: UIImage(systemName: "link"), state: uiState.draft.localOnly ? .off : .on) { (_) in + self.uiState.draft.localOnly = false + }, + ]) + } + func updateUIView(_ uiView: UITextView, context: Context) { if context.coordinator.skipSettingTextOnNextUpdate { context.coordinator.skipSettingTextOnNextUpdate = false @@ -145,6 +175,14 @@ struct MainComposeWrappedTextView: UIViewRepresentable { visibilityButton.image = UIImage(systemName: visibility.imageName) updateVisibilityMenu(visibilityButton) } + if let localOnlyButton = localOnlyButton { + if uiState.draft.localOnly { + localOnlyButton.image = UIImage(named: "link.broken") + } else { + localOnlyButton.image = UIImage(systemName: "link") + } + updateLocalOnlyMenu(localOnlyButton) + } context.coordinator.text = $text context.coordinator.didChange = textDidChange context.coordinator.uiState = uiState