Compare commits

...

4 Commits

Author SHA1 Message Date
Shadowfacts 5b03e0cf12
Fix follow notifications not showing names for users without explicit
display names
2020-09-09 18:45:38 -04:00
Shadowfacts 7c4bbfd730
Improve compose posting error messages 2020-09-09 18:33:59 -04:00
Shadowfacts e19a6528ad
Improve gallery expand animation
Use spring timing, slide in top/bottom controls
2020-09-08 23:41:15 -04:00
Shadowfacts f5110c773a
Tweak default font sizes 2020-09-07 18:49:25 -04:00
13 changed files with 99 additions and 49 deletions

View File

@ -50,22 +50,22 @@ public class Client {
let task = session.dataTask(with: request) { data, response, error in let task = session.dataTask(with: request) { data, response, error in
if let error = error { if let error = error {
completion(.failure(error)) completion(.failure(.networkError(error)))
return return
} }
guard let data = data, guard let data = data,
let response = response as? HTTPURLResponse else { let response = response as? HTTPURLResponse else {
completion(.failure(Error.invalidResponse)) completion(.failure(.invalidResponse))
return return
} }
guard response.statusCode == 200 else { guard response.statusCode == 200 else {
let mastodonError = try? self.decoder.decode(MastodonError.self, from: data) let mastodonError = try? self.decoder.decode(MastodonError.self, from: data)
let error = mastodonError.flatMap { Error.mastodonError($0.description) } ?? Error.unknownError let error: Error = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode)
completion(.failure(error)) completion(.failure(error))
return return
} }
guard let result = try? self.decoder.decode(Result.self, from: data) else { guard let result = try? self.decoder.decode(Result.self, from: data) else {
completion(.failure(Error.invalidModel)) completion(.failure(.invalidModel))
return return
} }
let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init) let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
@ -315,7 +315,8 @@ public class Client {
extension Client { extension Client {
public enum Error: LocalizedError { public enum Error: LocalizedError {
case unknownError case networkError(Swift.Error)
case unexpectedStatus(Int)
case invalidRequest case invalidRequest
case invalidResponse case invalidResponse
case invalidModel case invalidModel
@ -323,8 +324,13 @@ extension Client {
public var localizedDescription: String { public var localizedDescription: String {
switch self { switch self {
case .unknownError: case .networkError(let error):
return "Unknown Error" return "Network Error: \(error.localizedDescription)"
// todo: support more status codes
case .unexpectedStatus(413):
return "HTTP 413: Payload Too Large"
case .unexpectedStatus(let code):
return "HTTP Code \(code)"
case .invalidRequest: case .invalidRequest:
return "Invalid Request" return "Invalid Request"
case .invalidResponse: case .invalidResponse:

View File

@ -10,5 +10,5 @@ import Foundation
public enum Response<Result: Decodable> { public enum Response<Result: Decodable> {
case success(Result, Pagination?) case success(Result, Pagination?)
case failure(Error) case failure(Client.Error)
} }

View File

@ -22,16 +22,16 @@ public class InstanceSelector {
let request = URLRequest(url: url) let request = URLRequest(url: url)
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
if let error = error { if let error = error {
completion(.failure(error)) completion(.failure(.networkError(error)))
return return
} }
guard let data = data, guard let data = data,
let response = response as? HTTPURLResponse else { let response = response as? HTTPURLResponse else {
completion(.failure(Client.Error.invalidResponse)) completion(.failure(.invalidResponse))
return return
} }
guard response.statusCode == 200 else { guard response.statusCode == 200 else {
completion(.failure(Client.Error.unknownError)) completion(.failure(.unexpectedStatus(response.statusCode)))
return return
} }
guard let result = try? decoder.decode([Instance].self, from: data) else { guard let result = try? decoder.decode([Instance].self, from: data) else {

View File

@ -27,6 +27,10 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
} }
var animationSourceView: UIImageView? { sourceViews[currentIndex] } var animationSourceView: UIImageView? { sourceViews[currentIndex] }
var largeImageController: LargeImageViewController? {
// use protocol because page controllers may be loading or non-loading VCs
(pages[currentIndex] as? LargeImageAnimatableViewController)?.largeImageController
}
var animationImage: UIImage? { var animationImage: UIImage? {
if let page = pages[currentIndex] as? LargeImageAnimatableViewController, if let page = pages[currentIndex] as? LargeImageAnimatableViewController,
let image = page.animationImage { let image = page.animationImage {
@ -48,6 +52,9 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
override var prefersStatusBarHidden: Bool { override var prefersStatusBarHidden: Bool {
return true return true
} }
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .none
}
override var childForHomeIndicatorAutoHidden: UIViewController? { override var childForHomeIndicatorAutoHidden: UIViewController? {
return viewControllers?.first return viewControllers?.first
} }

View File

@ -12,14 +12,14 @@ import Combine
struct ComposeView: View { struct ComposeView: View {
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
@EnvironmentObject var mastodonController: MastodonController @EnvironmentObject var mastodonController: MastodonController
@EnvironmentObject var uiState: ComposeUIState @EnvironmentObject var uiState: ComposeUIState
@State var isPosting = false
@State var postProgress: Double = 0 @State private var isPosting = false
@State var postTotalProgress: Double = 0 @State private var postProgress: Double = 0
@State var isShowingPostErrorAlert = false @State private var postTotalProgress: Double = 0
@State var postError: Error? @State private var isShowingPostErrorAlert = false
@State private var postError: PostError?
private let stackPadding: CGFloat = 8 private let stackPadding: CGFloat = 8
@ -191,6 +191,9 @@ struct ComposeView: View {
case let .failure(error): case let .failure(error):
self.isShowingPostErrorAlert = true self.isShowingPostErrorAlert = true
self.postError = error self.postError = error
self.postProgress = 0
self.postTotalProgress = 0
self.isPosting = false
case let .success(uploadedAttachments): case let .success(uploadedAttachments):
let request = Client.createStatus(text: draft.text, let request = Client.createStatus(text: draft.text,
@ -222,7 +225,7 @@ struct ComposeView: View {
} }
} }
private func uploadAttachments(_ completion: @escaping (Result<[Attachment], Error>) -> Void) { private func uploadAttachments(_ completion: @escaping (Result<[Attachment], AttachmentUploadError>) -> Void) {
let group = DispatchGroup() let group = DispatchGroup()
var anyFailed = false var anyFailed = false
@ -239,13 +242,13 @@ struct ComposeView: View {
let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file") let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file")
let request = Client.upload(attachment: formAttachment, description: compAttachment.attachmentDescription) let request = Client.upload(attachment: formAttachment, description: compAttachment.attachmentDescription)
self.mastodonController.run(request) { (response) in self.mastodonController.run(request) { (response) in
postProgress += 1
switch response { switch response {
case let .failure(error): case let .failure(error):
uploadedAttachments[index] = .failure(error) uploadedAttachments[index] = .failure(error)
anyFailed = true anyFailed = true
case let .success(attachment, _): case let .success(attachment, _):
postProgress += 1
uploadedAttachments[index] = .success(attachment) uploadedAttachments[index] = .success(attachment)
} }
@ -274,14 +277,37 @@ struct ComposeView: View {
} }
} }
fileprivate struct AttachmentUploadError: LocalizedError { fileprivate protocol PostError: LocalizedError {}
extension PostError {
var localizedDescription: String {
if let self = self as? Client.Error {
return self.localizedDescription
} else if let self = self as? AttachmentUploadError {
return self.localizedDescription
} else {
return "Unknown Error"
}
}
}
extension Client.Error: PostError {}
fileprivate struct AttachmentUploadError: PostError {
let errors: [Error?] let errors: [Error?]
var localizedDescription: String { var localizedDescription: String {
return errors.enumerated().compactMap { (index, error) -> String? in return errors.enumerated().compactMap { (index, error) -> String? in
guard let error = error else { return nil } guard let error = error else { return nil }
return "Attachment \(index + 1): \(error.localizedDescription)" let description: String
}.joined(separator: ", ") // need to downcast to use more specific localizedDescription impl from Pachyderm
if let error = error as? Client.Error {
description = error.localizedDescription
} else {
description = error.localizedDescription
}
return "Attachment \(index + 1): \(description)"
}.joined(separator: ",\n")
} }
} }

View File

@ -13,6 +13,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
typealias ContentView = UIView & LargeImageContentView typealias ContentView = UIView & LargeImageContentView
weak var animationSourceView: UIImageView? weak var animationSourceView: UIImageView?
var largeImageController: LargeImageViewController? { self }
var animationImage: UIImage? { contentView.animationImage } var animationImage: UIImage? { contentView.animationImage }
var animationGifData: Data? { contentView.animationGifData } var animationGifData: Data? { contentView.animationGifData }
var dismissInteractionController: LargeImageInteractionController? var dismissInteractionController: LargeImageInteractionController?
@ -49,6 +50,9 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
override var prefersStatusBarHidden: Bool { override var prefersStatusBarHidden: Bool {
return true return true
} }
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .none
}
override var prefersHomeIndicatorAutoHidden: Bool { override var prefersHomeIndicatorAutoHidden: Bool {
return !controlsVisible return !controlsVisible

View File

@ -36,6 +36,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
var shrinkGestureEnabled = true var shrinkGestureEnabled = true
weak var animationSourceView: UIImageView? weak var animationSourceView: UIImageView?
var largeImageController: LargeImageViewController? { largeImageVC }
var animationImage: UIImage? { largeImageVC?.animationImage ?? animationSourceView?.image } var animationImage: UIImage? { largeImageVC?.animationImage ?? animationSourceView?.image }
var animationGifData: Data? { largeImageVC?.animationGifData } var animationGifData: Data? { largeImageVC?.animationGifData }
var dismissInteractionController: LargeImageInteractionController? var dismissInteractionController: LargeImageInteractionController?
@ -43,6 +44,9 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
override var prefersStatusBarHidden: Bool { override var prefersStatusBarHidden: Bool {
return true return true
} }
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .none
}
override var childForHomeIndicatorAutoHidden: UIViewController? { override var childForHomeIndicatorAutoHidden: UIViewController? {
return largeImageVC return largeImageVC
} }

View File

@ -11,6 +11,7 @@ import Gifu
protocol LargeImageAnimatableViewController: UIViewController { protocol LargeImageAnimatableViewController: UIViewController {
var animationSourceView: UIImageView? { get } var animationSourceView: UIImageView? { get }
var largeImageController: LargeImageViewController? { get }
var animationImage: UIImage? { get } var animationImage: UIImage? { get }
var animationGifData: Data? { get } var animationGifData: Data? { get }
var dismissInteractionController: LargeImageInteractionController? { get } var dismissInteractionController: LargeImageInteractionController? { get }
@ -37,7 +38,7 @@ extension LargeImageAnimatableViewController {
class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTransitioning { class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.2 return 0.5
} }
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
@ -47,19 +48,22 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
} }
let containerView = transitionContext.containerView let containerView = transitionContext.containerView
containerView.addSubview(toVC.view)
let finalVCFrame = transitionContext.finalFrame(for: toVC) let finalVCFrame = transitionContext.finalFrame(for: toVC)
guard let sourceView = toVC.animationSourceView, guard let sourceView = toVC.animationSourceView,
let sourceFrame = toVC.sourceViewFrame(in: fromVC.view), let sourceFrame = toVC.sourceViewFrame(in: fromVC.view),
let image = toVC.animationImage else { let image = toVC.animationImage else {
toVC.view.frame = finalVCFrame toVC.view.frame = finalVCFrame
transitionContext.completeTransition(!transitionContext.transitionWasCancelled) transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
return return
} }
// use alpha, becaus isHidden makes stack views re-layout // use alpha, because isHidden makes stack views re-layout
sourceView.alpha = 0 sourceView.alpha = 0
toVC.view.alpha = 0
toVC.largeImageController?.contentView.isHidden = true
toVC.largeImageController?.setControlsVisible(false, animated: false)
var finalFrameSize = finalVCFrame.inset(by: fromVC.view.safeAreaInsets).size var finalFrameSize = finalVCFrame.inset(by: fromVC.view.safeAreaInsets).size
let newWidth = finalFrameSize.width / image.size.width let newWidth = finalFrameSize.width / image.size.width
@ -81,21 +85,17 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
imageView.layer.maskedCorners = sourceView.layer.maskedCorners imageView.layer.maskedCorners = sourceView.layer.maskedCorners
imageView.layer.masksToBounds = true imageView.layer.masksToBounds = true
let blackView = UIView(frame: finalVCFrame) containerView.addSubview(toVC.view)
blackView.backgroundColor = .black
blackView.alpha = 0
containerView.addSubview(blackView)
containerView.addSubview(imageView) containerView.addSubview(imageView)
toVC.view.isHidden = true
let duration = transitionDuration(using: transitionContext) let duration = transitionDuration(using: transitionContext)
UIView.animate(withDuration: duration, animations: { let velocity = 1 / CGFloat(duration)
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 0.65, initialSpringVelocity: velocity, options: []) {
imageView.frame = finalFrame imageView.frame = finalFrame
imageView.layer.cornerRadius = 0 imageView.layer.cornerRadius = 0
blackView.alpha = 1 toVC.view.alpha = 1
}, completion: { _ in toVC.largeImageController?.setControlsVisible(true, animated: false)
} completion: { (_) in
// This shouldn't be necessary. I believe it's a workaround for using a XIB // This shouldn't be necessary. I believe it's a workaround for using a XIB
// for the large image VC. Without this, the final frame of the large image VC // for the large image VC. Without this, the final frame of the large image VC
// is not set to the propper rect (it uses the frame of the preview device // is not set to the propper rect (it uses the frame of the preview device
@ -103,15 +103,14 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
// (or UIKit does layout differently when loading the view) and this is not necessary. // (or UIKit does layout differently when loading the view) and this is not necessary.
toVC.view.frame = finalVCFrame toVC.view.frame = finalVCFrame
toVC.view.isHidden = false toVC.largeImageController?.contentView.isHidden = false
fromVC.view.isHidden = false fromVC.view.isHidden = false
blackView.removeFromSuperview()
imageView.removeFromSuperview() imageView.removeFromSuperview()
sourceView.alpha = 1 sourceView.alpha = 1
transitionContext.completeTransition(!transitionContext.transitionWasCancelled) transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}) }
} }
} }

View File

@ -77,11 +77,11 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
let peopleStr: String let peopleStr: String
switch people.count { switch people.count {
case 1: case 1:
peopleStr = people.first!.displayName peopleStr = people.first!.displayOrUserName
case 2: case 2:
peopleStr = people.first!.displayName + " and " + people.last!.displayName peopleStr = people.first!.displayOrUserName + " and " + people.last!.displayOrUserName
default: default:
peopleStr = people.dropLast().map { $0.displayName }.joined(separator: ", ") + ", and " + people.last!.displayName peopleStr = people.dropLast().map { $0.displayOrUserName }.joined(separator: ", ") + ", and " + people.last!.displayOrUserName
} }
actionLabel.text = "Followed by \(peopleStr)" actionLabel.text = "Followed by \(peopleStr)"

View File

@ -35,7 +35,7 @@ class ConversationMainStatusTableViewCell: BaseStatusTableViewCell {
profileAccessibilityElement.accessibilityFrameInContainerSpace = profileDetailContainerView.convert(profileDetailContainerView.frame, to: self) profileAccessibilityElement.accessibilityFrameInContainerSpace = profileDetailContainerView.convert(profileDetailContainerView.frame, to: self)
accessibilityElements = [profileAccessibilityElement!, contentWarningLabel!, collapseButton!, contentTextView!, totalFavoritesButton!, totalReblogsButton!, timestampAndClientLabel!, replyButton!, favoriteButton!, reblogButton!, moreButton!] accessibilityElements = [profileAccessibilityElement!, contentWarningLabel!, collapseButton!, contentTextView!, totalFavoritesButton!, totalReblogsButton!, timestampAndClientLabel!, replyButton!, favoriteButton!, reblogButton!, moreButton!]
contentTextView.defaultFont = .systemFont(ofSize: 20) contentTextView.defaultFont = .systemFont(ofSize: 18)
} }
override func updateUI(statusID: String, state: StatusState) { override func updateUI(statusID: String, state: StatusState) {

View File

@ -48,6 +48,8 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
// todo: double check this on RTL layouts // todo: double check this on RTL layouts
replyButton.imageView!.leadingAnchor.constraint(equalTo: contentTextView.leadingAnchor).isActive = true replyButton.imageView!.leadingAnchor.constraint(equalTo: contentTextView.leadingAnchor).isActive = true
contentTextView.defaultFont = .systemFont(ofSize: 16)
} }
override func createObserversIfNecessary() { override func createObserversIfNecessary() {

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="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"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -111,7 +111,7 @@
<rect key="frame" x="0.0" y="83" width="277" height="86.5"/> <rect key="frame" x="0.0" y="83" width="277" height="86.5"/>
<string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string> <string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string>
<color key="textColor" systemColor="labelColor"/> <color key="textColor" systemColor="labelColor"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/> <fontDescription key="fontDescription" type="system" pointSize="16"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/> <textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView> </textView>
<view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target"> <view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
@ -240,7 +240,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="bubble.left.and.bubble.right" catalog="system" width="128" height="96"/> <image name="bubble.left.and.bubble.right" catalog="system" width="128" height="96"/>
<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"/>

View File

@ -21,6 +21,8 @@ struct WrappedProgressView: UIViewRepresentable {
func updateUIView(_ uiView: UIProgressView, context: Context) { func updateUIView(_ uiView: UIProgressView, context: Context) {
if total > 0 { if total > 0 {
uiView.setProgress(Float(value / total), animated: true) uiView.setProgress(Float(value / total), animated: true)
} else {
uiView.setProgress(0, animated: true)
} }
} }
} }