Compare commits

..

12 Commits

17 changed files with 242 additions and 194 deletions

View File

@ -1,5 +1,14 @@
# Changelog
## 2022.1 (27)
Features/Improvements:
- Add emoji picker button to Compose screen toolbar
- Preference to disable reply/like/reblog/more buttons on statuses in the timeline
- iPad: Sidebar toggle button
Bugfixes:
- Fix crash when displaying malformed statuses
## 2022.1 (26)
This build contains hotfixes for several crashes that may occur when logged-in to a Pixelfed account.

View File

@ -2165,7 +2165,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2196,7 +2196,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2306,7 +2306,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2333,7 +2333,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;

View File

@ -34,11 +34,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
let config = PLCrashReporterConfig(signalHandlerType: .BSD, symbolicationStrategy: .all)
AppDelegate.crashReporter = PLCrashReporter(configuration: config)
if AppDelegate.crashReporter.hasPendingCrashReport() {
let data = try! AppDelegate.crashReporter.loadPendingCrashReportDataAndReturnError()
if AppDelegate.crashReporter.hasPendingCrashReport(),
let data = try? AppDelegate.crashReporter.loadPendingCrashReportDataAndReturnError(),
let report = try? PLCrashReport(data: data) {
AppDelegate.crashReporter.purgePendingCrashReport()
let report = try! PLCrashReport(data: data)
AppDelegate.pendingCrashReport = report
}

View File

@ -42,6 +42,7 @@ class Preferences: Codable, ObservableObject {
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
self.defaultPostVisibility = try container.decode(Status.Visibility.self, forKey: .defaultPostVisibility)
self.automaticallySaveDrafts = try container.decode(Bool.self, forKey: .automaticallySaveDrafts)
@ -81,6 +82,7 @@ class Preferences: Codable, ObservableObject {
try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames)
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline)
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
try container.encode(automaticallySaveDrafts, forKey: .automaticallySaveDrafts)
@ -118,6 +120,7 @@ class Preferences: Codable, ObservableObject {
@Published var hideCustomEmojiInUsernames = false
@Published var showIsStatusReplyIcon = false
@Published var alwaysShowStatusVisibilityIcon = false
@Published var hideActionsInTimeline = false
// MARK: Composing
@Published var defaultPostVisibility = Status.Visibility.public
@ -160,6 +163,7 @@ class Preferences: Codable, ObservableObject {
case hideCustomEmojiInUsernames
case showIsStatusReplyIcon
case alwaysShowStatusVisibilityIcon
case hideActionsInTimeline
case defaultPostVisibility
case automaticallySaveDrafts

View File

@ -60,7 +60,7 @@ struct ComposeAutocompleteMentionsView: View {
HStack(spacing: 8) {
ForEach(accounts, id: \.id) { (account) in
Button {
uiState.autocompleteHandler?.autocomplete(with: "@\(account.acct)")
uiState.currentInput?.autocomplete(with: "@\(account.acct)")
} label: {
HStack(spacing: 4) {
ComposeAvatarImageView(url: account.avatar)
@ -234,6 +234,12 @@ struct ComposeAutocompleteEmojisView: View {
} else {
horizontalScrollView
.onReceive(uiState.$autocompleteState, perform: queryChanged)
.onAppear {
if uiState.shouldEmojiAutocompletionBeginExpanded {
expanded = true
uiState.shouldEmojiAutocompletionBeginExpanded = false
}
}
}
}
@ -242,7 +248,7 @@ struct ComposeAutocompleteEmojisView: View {
HStack(spacing: 8) {
ForEach(emojis, id: \.shortcode) { (emoji) in
Button {
uiState.autocompleteHandler?.autocomplete(with: ":\(emoji.shortcode):")
uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
} label: {
HStack(spacing: 4) {
CustomEmojiImageView(emoji: emoji)
@ -306,7 +312,7 @@ struct ComposeAutocompleteHashtagsView: View {
HStack(spacing: 8) {
ForEach(hashtags, id: \.name) { (hashtag) in
Button {
uiState.autocompleteHandler?.autocomplete(with: "#\(hashtag.name)")
uiState.currentInput?.autocomplete(with: "#\(hashtag.name)")
} label: {
Text(verbatim: "#\(hashtag.name)")
.foregroundColor(Color(UIColor.label))

View File

@ -73,22 +73,27 @@ struct ComposeEmojiTextField: UIViewRepresentable {
return Coordinator()
}
class Coordinator: NSObject, UITextFieldDelegate, ComposeAutocompleteHandler {
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
weak var textField: UITextField?
var text: Binding<String>!
var uiState: ComposeUIState!
// break retained cycle through ComposeUIState.currentInput
unowned var uiState: ComposeUIState!
var didChange: ((String) -> Void)?
var didEndEditing: (() -> Void)?
var skipSettingTextOnNextUpdate = false
var toolbarElements: [ComposeUIState.ToolbarElement] {
[.emojiPicker]
}
@objc func didChange(_ textField: UITextField) {
text.wrappedValue = textField.text ?? ""
didChange?(text.wrappedValue)
}
func textFieldDidBeginEditing(_ textField: UITextField) {
uiState.autocompleteHandler = self
uiState.currentInput = self
updateAutocompleteState(textField: textField)
}
@ -103,6 +108,13 @@ struct ComposeEmojiTextField: UIViewRepresentable {
self.updateAutocompleteState(textField: textField)
}
func beginAutocompletingEmoji() {
textField?.insertText(":")
}
func applyFormat(_ format: StatusFormat) {
}
func autocomplete(with string: String) {
guard let textField = textField,
let text = textField.text,

View File

@ -15,6 +15,9 @@ protocol ComposeHostingControllerDelegate: AnyObject {
func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool
}
private let VISIBILITY_BAR_BUTTON_TAG = 42001
private let LOCAL_ONLY_BAR_BUTTON_TAG = 42002
class ComposeHostingController: UIHostingController<ComposeContainerView> {
weak var delegate: ComposeHostingControllerDelegate?
@ -31,8 +34,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
private var mainToolbar: UIToolbar!
private var inputAccessoryToolbar: UIToolbar!
private var visibilityBarButtonItems = [UIBarButtonItem]()
private var localOnlyItems = [UIBarButtonItem]()
override var inputAccessoryView: UIView? { inputAccessoryToolbar }
@ -56,12 +57,18 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
// 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()
mainToolbar = UIToolbar()
mainToolbar.translatesAutoresizingMaskIntoConstraints = false
mainToolbar.isAccessibilityElement = true
setupToolbarItems(toolbar: mainToolbar, input: nil)
inputAccessoryToolbar = UIToolbar()
inputAccessoryToolbar.translatesAutoresizingMaskIntoConstraints = false
inputAccessoryToolbar.isAccessibilityElement = true
setupToolbarItems(toolbar: inputAccessoryToolbar, input: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(composeKeyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(composeKeyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(composeKeyboardDidHide(_:)), 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
updateAdditionalSafeAreaInsets()
@ -87,6 +94,12 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
DraftsManager.save()
}
.store(in: &cancellables)
self.uiState.$currentInput
.sink { [unowned self] in
self.setupToolbarItems(toolbar: self.inputAccessoryToolbar, input: $0)
}
.store(in: &cancellables)
}
required init?(coder aDecoder: NSCoder) {
@ -116,37 +129,68 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
DraftsManager.save()
}
private func createToolbar() -> UIToolbar {
let toolbar = UIToolbar()
toolbar.translatesAutoresizingMaskIntoConstraints = false
toolbar.isAccessibilityElement = true
private func setupToolbarItems(toolbar: UIToolbar, input: ComposeInput?) {
var items: [UIBarButtonItem] = []
items.append(UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed)))
let visibilityItem = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil)
visibilityBarButtonItems.append(visibilityItem)
visibilityChanged(draft.visibility)
toolbar.items = [
UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed)),
visibilityItem,
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPresed))
]
visibilityItem.tag = VISIBILITY_BAR_BUTTON_TAG
items.append(visibilityItem)
if mastodonController.instanceFeatures.localOnlyPosts {
let item = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil)
toolbar.items!.insert(item, at: 2)
localOnlyItems.append(item)
item.tag = LOCAL_ONLY_BAR_BUTTON_TAG
items.append(item)
localOnlyChanged(draft.localOnly)
}
return toolbar
if input?.toolbarElements.contains(.emojiPicker) == true {
items.append(UIBarButtonItem(image: UIImage(systemName: "face.smiling"), style: .plain, target: self, action: #selector(emojiPickerButtonPressed)))
}
items.append(UIBarButtonItem(systemItem: .flexibleSpace))
if input?.toolbarElements.contains(.formattingButtons) == true,
Preferences.shared.statusContentType != .plain {
for (idx, format) in StatusFormat.allCases.enumerated() {
let item: UIBarButtonItem
if let image = format.image {
item = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(formatButtonPressed(_:)))
} else if let (str, attributes) = format.title {
item = UIBarButtonItem(title: str, style: .plain, target: self, action: #selector(formatButtonPressed(_:)))
item.setTitleTextAttributes(attributes, for: .normal)
item.setTitleTextAttributes(attributes, for: .highlighted)
} else {
fatalError("StatusFormat must have either image or title")
}
item.tag = StatusFormat.allCases.firstIndex(of: format)!
item.accessibilityLabel = format.accessibilityLabel
items.append(item)
if idx != StatusFormat.allCases.count - 1 {
let spacer = UIBarButtonItem(systemItem: .fixedSpace)
spacer.width = 8
items.append(spacer)
}
}
items.append(UIBarButtonItem(systemItem: .flexibleSpace))
}
items.append(UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPresed)))
toolbar.items = items
visibilityChanged(draft.visibility)
localOnlyChanged(draft.localOnly)
}
private func updateAdditionalSafeAreaInsets() {
additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: toolbarHeight, right: 0)
}
@objc private func keyboardWillShow(_ notification: Foundation.Notification) {
@objc private func composeKeyboardWillShow(_ notification: Foundation.Notification) {
keyboardWillShow(accessoryView: inputAccessoryToolbar, notification: notification)
}
@ -157,7 +201,7 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
accessoryView.isHidden = false
}
@objc private func keyboardWillHide(_ notification: Foundation.Notification) {
@objc private func composeKeyboardWillHide(_ notification: Foundation.Notification) {
keyboardWillHide(accessoryView: inputAccessoryToolbar, notification: notification)
}
@ -189,7 +233,7 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
}
}
@objc private func keyboardDidHide(_ notification: Foundation.Notification) {
@objc private func composeKeyboardDidHide(_ notification: Foundation.Notification) {
keyboardDidHide(accessoryView: inputAccessoryToolbar, notification: notification)
}
@ -198,7 +242,10 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
}
private func visibilityChanged(_ newVisibility: Status.Visibility) {
for item in visibilityBarButtonItems {
for toolbar in [mainToolbar, inputAccessoryToolbar] {
guard let item = toolbar?.items?.first(where: { $0.tag == VISIBILITY_BAR_BUTTON_TAG }) else {
continue
}
item.image = UIImage(systemName: newVisibility.imageName)
item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName)
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
@ -212,7 +259,10 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
}
private func localOnlyChanged(_ localOnly: Bool) {
for item in localOnlyItems {
for toolbar in [mainToolbar, inputAccessoryToolbar] {
guard let item = toolbar?.items?.first(where: { $0.tag == LOCAL_ONLY_BAR_BUTTON_TAG }) else {
continue
}
if localOnly {
item.image = UIImage(named: "link.broken")
item.accessibilityLabel = "Local-only"
@ -260,6 +310,19 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
draft.contentWarningEnabled = !draft.contentWarningEnabled
}
@objc func formatButtonPressed(_ sender: UIBarButtonItem) {
let format = StatusFormat.allCases[sender.tag]
uiState.currentInput?.applyFormat(format)
}
@objc func emojiPickerButtonPressed() {
guard uiState.autocompleteState == nil else {
return
}
uiState.shouldEmojiAutocompletionBeginExpanded = true
uiState.currentInput?.beginAutocompletingEmoji()
}
@objc func draftsButtonPresed() {
let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!, exclude: draft)
draftsVC.delegate = self

View File

@ -31,7 +31,8 @@ class ComposeUIState: ObservableObject {
var composeDrawingMode: ComposeDrawingMode?
weak var autocompleteHandler: ComposeAutocompleteHandler?
var shouldEmojiAutocompletionBeginExpanded = false
@Published var currentInput: ComposeInput?
init(draft: Draft) {
self.draft = draft
@ -60,6 +61,19 @@ extension ComposeUIState {
}
}
protocol ComposeAutocompleteHandler: AnyObject {
protocol ComposeInput: AnyObject {
var toolbarElements: [ComposeUIState.ToolbarElement] { get }
func autocomplete(with string: String)
func applyFormat(_ format: StatusFormat)
func beginAutocompletingEmoji()
}
extension ComposeUIState {
enum ToolbarElement {
case emojiPicker
case formattingButtons
}
}

View File

@ -37,18 +37,21 @@ class EmojiPickerCollectionViewController: UICollectionViewController {
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
let itemWidth = NSCollectionLayoutDimension.fractionalWidth(1.0 / 10)
let itemSize = NSCollectionLayoutSize(widthDimension: itemWidth, heightDimension: itemWidth)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemWidth)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
group.interItemSpacing = .fixed(4)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 4
let layout = UICollectionViewCompositionalLayout(section: section)
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let hSizeClass = environment.traitCollection.horizontalSizeClass
let itemWidth = NSCollectionLayoutDimension.fractionalWidth(1.0 / (hSizeClass == .compact ? 10 : 20))
let itemSize = NSCollectionLayoutSize(widthDimension: itemWidth, heightDimension: itemWidth)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemWidth)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
group.interItemSpacing = .fixed(4)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 4
return section
}
super.init(collectionViewLayout: layout)
}

View File

@ -39,7 +39,7 @@ struct EmojiPickerWrapper: UIViewControllerRepresentable {
}
func selectedEmoji(_ emoji: Emoji) {
uiState.autocompleteHandler?.autocomplete(with: ":\(emoji.shortcode):")
uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
uiState.autocompleteState = nil
}
}

View File

@ -57,9 +57,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
@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()
@ -69,101 +66,9 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
textView.font = .systemFont(ofSize: 20)
textView.textContainer.lineBreakMode = .byWordWrapping
context.coordinator.textView = textView
uiState.autocompleteHandler = context.coordinator
let visibilityButton = UIBarButtonItem(image: UIImage(systemName: visibility.imageName), style: .plain, target: nil, action: nil)
updateVisibilityMenu(visibilityButton)
let toolbar = UIToolbar()
toolbar.translatesAutoresizingMaskIntoConstraints = false
toolbar.items = [
UIBarButtonItem(title: "CW", style: .plain, target: nil, action: #selector(ComposeHostingController.cwButtonPressed)),
visibilityButton,
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
] + createFormattingButtons(coordinator: context.coordinator) + [
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
UIBarButtonItem(title: "Drafts", style: .plain, target: nil, action: #selector(ComposeHostingController.draftsButtonPresed)),
]
textView.inputAccessoryView = toolbar
// can't modify @State during view update
DispatchQueue.main.async {
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)
return textView
}
private func createFormattingButtons(coordinator: Coordinator) -> [UIBarButtonItem] {
guard Preferences.shared.statusContentType != .plain else {
return []
}
var formatButtons = StatusFormat.allCases.map { (format) -> UIBarButtonItem in
let item: UIBarButtonItem
if let image = format.image {
item = UIBarButtonItem(image: image, style: .plain, target: coordinator, action: #selector(Coordinator.formatButtonPressed(_:)))
} else if let (str, attributes) = format.title {
item = UIBarButtonItem(title: str, style: .plain, target: coordinator, action: #selector(Coordinator.formatButtonPressed(_:)))
item.setTitleTextAttributes(attributes, for: .normal)
item.setTitleTextAttributes(attributes, for: .highlighted)
} else {
fatalError("StatusFormat must have either an image or a title")
}
item.tag = StatusFormat.allCases.firstIndex(of: format)!
item.accessibilityLabel = format.accessibilityLabel
return item
}
for i in (1..<StatusFormat.allCases.count).reversed() {
let spacer = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
spacer.width = 8
formatButtons.insert(spacer, at: i)
}
return formatButtons
}
private func updateVisibilityMenu(_ visibilityButton: UIBarButtonItem) {
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)
}
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
@ -171,18 +76,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
uiView.text = text
}
if let visibilityButton = visibilityButton {
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
@ -232,17 +125,23 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
command.attributes.remove(.disabled)
}
}
}
class Coordinator: NSObject, UITextViewDelegate, ComposeAutocompleteHandler, ComposeTextViewCaretScrolling {
class Coordinator: NSObject, UITextViewDelegate, ComposeInput, ComposeTextViewCaretScrolling {
weak var textView: UITextView?
var text: Binding<String>
var didChange: (UITextView) -> Void
var uiState: ComposeUIState
// break retained cycle through ComposeUIState.currentInput
unowned var uiState: ComposeUIState
var caretScrollPositionAnimator: UIViewPropertyAnimator?
var skipSettingTextOnNextUpdate = false
var toolbarElements: [ComposeUIState.ToolbarElement] {
[.emojiPicker, .formattingButtons]
}
init(text: Binding<String>, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) {
self.text = text
self.didChange = didChange
@ -256,11 +155,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
ensureCursorVisible(textView: textView)
}
@objc func formatButtonPressed(_ sender: UIBarButtonItem) {
let format = StatusFormat.allCases[sender.tag]
applyFormat(format)
}
func applyFormat(_ format: StatusFormat) {
guard let textView = textView,
textView.isFirstResponder,
@ -281,24 +175,13 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
}
}
@objc func keyboardWillShow(_ notification: Foundation.Notification) {
uiState.delegate?.keyboardWillShow(accessoryView: textView!.inputAccessoryView!, notification: notification)
}
@objc func keyboardWillHide(_ notification: Foundation.Notification) {
uiState.delegate?.keyboardWillHide(accessoryView: textView!.inputAccessoryView!, notification: notification)
}
@objc func keyboardDidHide(_ notification: Foundation.Notification) {
uiState.delegate?.keyboardDidHide(accessoryView: textView!.inputAccessoryView!, notification: notification)
}
func textViewDidBeginEditing(_ textView: UITextView) {
uiState.autocompleteHandler = self
uiState.currentInput = self
updateAutocompleteState()
}
func textViewDidEndEditing(_ textView: UITextView) {
uiState.currentInput = nil
updateAutocompleteState()
}
@ -310,6 +193,10 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
self.updateAutocompleteState()
}
func beginAutocompletingEmoji() {
textView?.insertText(":")
}
func autocomplete(with string: String) {
guard let textView = textView,
let text = textView.text,

View File

@ -30,7 +30,7 @@ class ListTimelineViewController: TimelineTableViewController {
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(editButtonPressed))
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(editListButtonPressed))
}
override func viewDidAppear(_ animated: Bool) {
@ -43,18 +43,18 @@ class ListTimelineViewController: TimelineTableViewController {
func presentEdit(animated: Bool) {
let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController)
editListAccountsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneButtonPressed))
editListAccountsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed))
let navController = UINavigationController(rootViewController: editListAccountsController)
present(navController, animated: animated)
}
// MARK: - Interaction
@objc func editButtonPressed() {
@objc func editListButtonPressed() {
presentEdit(animated: true)
}
@objc func doneButtonPressed() {
@objc func editListDoneButtonPressed() {
dismiss(animated: true)
// todo: show loading indicator

View File

@ -46,8 +46,6 @@ class MainSplitViewController: UISplitViewController {
preferredDisplayMode = .oneBesideSecondary
preferredSplitBehavior = .tile
presentsWithGesture = false
showsSecondaryOnlyButton = false
delegate = self
sidebar = MainSidebarViewController(mastodonController: mastodonController)

View File

@ -56,6 +56,9 @@ struct AppearancePrefsView : View {
Toggle(isOn: $preferences.alwaysShowStatusVisibilityIcon) {
Text("Always Show Status Visibility Icons")
}
Toggle(isOn: $preferences.hideActionsInTimeline) {
Text("Hide Actions on Timeline")
}
}
}
}

View File

@ -109,10 +109,20 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
case "p":
attributed.append(NSAttributedString(string: "\n\n"))
case "em", "i":
let currentFont: UIFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? self.font!
let currentFont: UIFont
if attributed.length == 0 {
currentFont = defaultFont
} else {
currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? defaultFont
}
attributed.addAttribute(.font, value: currentFont.withTraits(.traitItalic)!, range: attributed.fullRange)
case "strong", "b":
let currentFont: UIFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? self.font!
let currentFont: UIFont
if attributed.length == 0 {
currentFont = defaultFont
} else {
currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? defaultFont
}
attributed.addAttribute(.font, value: currentFont.withTraits(.traitBold)!, range: attributed.fullRange)
case "del":
attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange)

View File

@ -22,6 +22,10 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
@IBOutlet weak var reblogLabel: EmojiLabel!
@IBOutlet weak var timestampLabel: UILabel!
@IBOutlet weak var pinImageView: UIImageView!
@IBOutlet weak var actionsContainerView: UIView!
@IBOutlet weak var actionsContainerHeightConstraint: NSLayoutConstraint!
@IBOutlet weak var verticalStackToActionsContainerConstraint: NSLayoutConstraint!
@IBOutlet weak var verticalStackToSuperviewConstraint: NSLayoutConstraint!
var reblogStatusID: String?
var rebloggerID: String?
@ -51,6 +55,8 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
contentTextView.defaultFont = .systemFont(ofSize: 16)
avatarImageView.addInteraction(UIContextMenuInteraction(delegate: self))
updateActionsVisibility()
}
override func createObserversIfNecessary() {
@ -121,9 +127,33 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
} else {
metaIndicatorsView.allowedIndicators = .all.subtracting(.reply)
}
let oldState = actionsContainerView.isHidden
if oldState != Preferences.shared.hideActionsInTimeline {
updateActionsVisibility()
// not really accurate, but it notifies the vc our height has changed
delegate?.statusCellCollapsedStateChanged(self)
}
super.updateStatusIconsForPreferences(status)
}
private func updateActionsVisibility() {
if Preferences.shared.hideActionsInTimeline {
actionsContainerView.isHidden = true
actionsContainerHeightConstraint.isActive = false
verticalStackToSuperviewConstraint.isActive = true
verticalStackToActionsContainerConstraint.isActive = false
} else {
actionsContainerView.isHidden = false
// sometimes this constraint is nil for reasons i can't discern
// not re-activating in that case doesn't seem to make a difference
actionsContainerHeightConstraint?.isActive = true
verticalStackToSuperviewConstraint.isActive = false
verticalStackToActionsContainerConstraint.isActive = true
}
}
private func updateTimestamp() {
// if the mastodonController is nil (i.e. the delegate is nil), then the screen this cell was a part of has been deallocated
// so we bail out immediately, since there's nothing to update

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19529" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" 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="19519"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -218,11 +218,17 @@
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="qBn-Gk-DCa" secondAttribute="bottom" id="gxb-hp-7lU"/>
<constraint firstAttribute="trailingMargin" secondItem="gIY-Wp-RSk" secondAttribute="trailing" id="hKk-kO-wFT"/>
<constraint firstItem="qBn-Gk-DCa" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="iLD-VU-ixJ"/>
<constraint firstAttribute="bottom" secondItem="gIY-Wp-RSk" secondAttribute="bottom" id="kq7-bk-S8j"/>
<constraint firstAttribute="bottom" secondItem="TUP-Nz-5Yh" secondAttribute="bottom" id="rmQ-QM-Llu"/>
<constraint firstItem="qBn-Gk-DCa" firstAttribute="top" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="4" id="tKU-VP-n8P"/>
<constraint firstItem="qBn-Gk-DCa" firstAttribute="width" secondItem="QMP-j2-HLn" secondAttribute="width" id="v1v-Pp-ubE"/>
<constraint firstItem="QMP-j2-HLn" firstAttribute="leading" secondItem="ve3-Y1-NQH" secondAttribute="leading" id="zeW-tQ-uJl"/>
</constraints>
<variation key="default">
<mask key="constraints">
<exclude reference="kq7-bk-S8j"/>
</mask>
</variation>
</view>
</subviews>
<constraints>
@ -240,6 +246,8 @@
</constraints>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<connections>
<outlet property="actionsContainerHeightConstraint" destination="1FK-Er-G11" id="9yN-NJ-GjN"/>
<outlet property="actionsContainerView" destination="TUP-Nz-5Yh" id="NHM-5L-Odz"/>
<outlet property="attachmentsView" destination="nbq-yr-2mA" id="SVm-zl-mPb"/>
<outlet property="avatarImageView" destination="QMP-j2-HLn" id="xfS-v8-Gzu"/>
<outlet property="cardView" destination="LKo-VB-XWl" id="6X5-8P-Ata"/>
@ -257,6 +265,8 @@
<outlet property="replyButton" destination="rKF-yF-KIa" id="rka-q1-o4a"/>
<outlet property="timestampLabel" destination="35d-EA-ReR" id="Ny2-nV-nqP"/>
<outlet property="usernameLabel" destination="j89-zc-SFa" id="bXX-FZ-fCp"/>
<outlet property="verticalStackToActionsContainerConstraint" destination="4KL-a3-qyf" id="ooW-DI-8AX"/>
<outlet property="verticalStackToSuperviewConstraint" destination="kq7-bk-S8j" id="Rfv-Bn-8B4"/>
</connections>
<point key="canvasLocation" x="29.600000000000001" y="79.160419790104953"/>
</view>