Compare commits

...

7 Commits

6 changed files with 101 additions and 16 deletions

View File

@ -1,5 +1,19 @@
# Changelog
## 2020.1 (13)
This is another quick build to fix a couple of severe issues on the Compose screen.
Features/Improvements:
- When composing posts, ensure the cursor is always visible and does not scroll below the keyboard while typing
Bugfixes:
- Fix builtin iOS keyboard suggestions not working in text fields on the Compose screen
- Fix crash when ending dictation in the CW field
- Fix broken layout on the Compose when replying to certain posts
## 2020.1 (12)
This build is a hotfix for the issue of being unable to login to certain instances. The changelog for the previous build is included below.
## 2020.1 (11)
This release is primarily focused on bug fixes with the one key feature of autocomplete suggestions when typing in the Compose screen. It also fixes an issue on the various new sizes of iPhone 12, so if you're getting a new device, make sure to update.

View File

@ -2265,7 +2265,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
@ -2294,7 +2294,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 11;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;

View File

@ -58,6 +58,10 @@ struct ComposeContentWarningTextField: UIViewRepresentable {
}
func textFieldDidChangeSelection(_ textField: UITextField) {
// Update text binding before potentially triggering SwiftUI view update.
// See comment in MainComposeTextView.Coordinator.textViewDidChangeSelection
text.wrappedValue = textField.text ?? ""
updateAutocompleteState(textField: textField)
}
@ -143,6 +147,10 @@ struct ComposeContentWarningTextField: UIViewRepresentable {
let selectedRangeStartUTF16 = textField.offset(from: textField.beginningOfDocument, to: selectedRange.start)
let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
guard cursorIndex != text.startIndex else {
return nil
}
var lastWordStartIndex = text.index(before: cursorIndex)
while true {
let c = text[lastWordStartIndex]

View File

@ -41,11 +41,10 @@ struct ComposeReplyView: View {
ComposeReplyContentView(status: status) { (newHeight) in
self.contentHeight = newHeight
}
.frame(height: contentHeight)
.offset(x: -4, y: -8)
.padding(.bottom, -8)
}
.frame(minHeight: 50 + 8)
.frame(height: max(50, contentHeight ?? 0) + 8)
}
.padding(.bottom, -8)
}

View File

@ -144,6 +144,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text
if let visibilityButton = visibilityButton {
visibilityButton.image = UIImage(systemName: visibility.imageName)
updateVisibilityMenu(visibilityButton)
@ -175,6 +176,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
var text: Binding<String>
var didChange: (UITextView) -> Void
var uiState: ComposeUIState
private var caretScrollPositionAnimator: UIViewPropertyAnimator?
init(text: Binding<String>, uiState: ComposeUIState, didChange: @escaping (UITextView) -> Void) {
self.text = text
@ -185,6 +187,54 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
func textViewDidChange(_ textView: UITextView) {
text.wrappedValue = textView.text
didChange(textView)
ensureCursorVisible()
}
private func ensureCursorVisible() {
guard let textView = textView,
textView.isFirstResponder,
let range = textView.selectedTextRange,
let scrollView = findParentScrollView() else {
return
}
// We use a UIViewProperty animator to change the scroll view position so that we can store the currently
// running one on the Coordinator. This allows us to cancel the running one, preventing multiple animations
// from attempting to change the scroll view offset simultaneously, causing it to jitter around. This can
// happen if the user is pressing return and quickly creating many new lines.
if let existing = caretScrollPositionAnimator {
existing.stopAnimation(true)
}
let cursorRect = textView.caretRect(for: range.start)
var rectToMakeVisible = textView.convert(cursorRect, to: scrollView)
// move Y position of the rect that will be made visible down by the cursor's height so that there's
// some space between the bottom of the line of text being edited and the top of the keyboard
rectToMakeVisible.origin.y += cursorRect.height
let animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
}
self.caretScrollPositionAnimator = animator
animator.startAnimation()
}
private func findParentScrollView() -> UIScrollView? {
guard let textView = textView else { return nil }
var current: UIView = textView
while let superview = current.superview {
if let scrollView = superview as? UIScrollView {
return scrollView
} else {
current = superview
}
}
return nil
}
@objc func formatButtonPressed(_ sender: UIBarButtonItem) {
@ -227,6 +277,17 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
}
func textViewDidChangeSelection(_ textView: UITextView) {
// Update the value of the text binding.
// Sometimes, when the user accepts an autocomplete suggestion from the system keyboard, the system
// calls didChangeSelection before textDidChange, resulting in a loop where the updating the Tusker autocomplete
// state in didChangeSection (via updateAutocompleteState) triggers a new SwiftUI view update,
// but when that SwiftUI update is handled, the model still has the old text (from prior to accepting the autocomplete
// suggestion), meaning the UITextView's text gets set back to whatever it was prior to the system autocomplete.
// To work around that, we also update the text binding in didChangeSelection, to ensure that, if the autocomplete state
// does change and trigger a SwiftUI update, the binding will have the correct text that was produced by the system
// autocompletion.
text.wrappedValue = textView.text ?? ""
updateAutocompleteState()
}

View File

@ -57,7 +57,7 @@ class InstanceSelectorTableViewController: UITableViewController {
dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
switch item {
case let .selected(instance):
case let .selected(_, instance):
let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell
cell.updateUI(instance: instance)
return cell
@ -113,8 +113,9 @@ class InstanceSelectorTableViewController: UITableViewController {
private func updateSpecificInstance(domain: String) {
let components = parseURLComponents(input: domain)
let url = components.url!
let client = Client(baseURL: components.url!)
let client = Client(baseURL: url)
let request = Client.getInstance()
client.run(request) { (response) in
var snapshot = self.dataSource.snapshot()
@ -126,7 +127,7 @@ class InstanceSelectorTableViewController: UITableViewController {
if !snapshot.sectionIdentifiers.contains(.selected) {
snapshot.appendSections([.selected])
}
snapshot.appendItems([.selected(instance)], toSection: .selected)
snapshot.appendItems([.selected(url, instance)], toSection: .selected)
DispatchQueue.main.async {
self.dataSource.apply(snapshot)
}
@ -168,10 +169,11 @@ class InstanceSelectorTableViewController: UITableViewController {
return
}
switch item {
case let .selected(instance):
// we can't just turn the URI string from the API into a URL instance, because Mastodon only includes the domain in the "URI"
let components = parseURLComponents(input: instance.uri)
delegate.didSelectInstance(url: components.url!)
case let .selected(url, _):
// We can't rely on the URI reported by the instance API endpoint, because improperly configured instances may
// return a domain that they don't listen on. Instead, use the actual base URL that was used to make the /api/v1/instance
// request, since we know for certain that succeeded, otherwise this item wouldn't exist.
delegate.didSelectInstance(url: url)
case let .recommended(instance):
var components = URLComponents()
components.scheme = "https"
@ -188,13 +190,13 @@ extension InstanceSelectorTableViewController {
case recommendedInstances
}
enum Item: Equatable, Hashable {
case selected(Instance)
case selected(URL, Instance)
case recommended(InstanceSelector.Instance)
static func ==(lhs: Item, rhs: Item) -> Bool {
if case let .selected(instance) = lhs,
case let .selected(other) = rhs {
return instance.uri == other.uri
if case let .selected(url, instance) = lhs,
case let .selected(otherUrl, other) = rhs {
return url == otherUrl && instance.uri == other.uri
} else if case let .recommended(instance) = lhs,
case let .recommended(other) = rhs {
return instance.domain == other.domain
@ -204,8 +206,9 @@ extension InstanceSelectorTableViewController {
func hash(into hasher: inout Hasher) {
switch self {
case let .selected(instance):
case let .selected(url, instance):
hasher.combine(Section.selected)
hasher.combine(url)
hasher.combine(instance.uri)
case let .recommended(instance):
hasher.combine(Section.recommendedInstances)