Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions App/Composition/ComposeTextViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import AwfulSettings
import AwfulTheming
import MRProgress
import UIKit
import ImgurAnonymousAPI

class ComposeTextViewController: ViewController {
@FoilDefaultStorage(Settings.enableHaptics) private var enableHaptics
Expand Down Expand Up @@ -197,6 +198,49 @@ class ComposeTextViewController: ViewController {
self?.focusInitialFirstResponder()
return
}

// Handle Imgur authentication errors
if let imgurError = error as? ImageUploadError {
switch imgurError {
case .authenticationRequired:
// Present authentication flow
let presenter = self?.navigationController ?? self
let alert = UIAlertController(
title: imgurError.errorDescription ?? "Authentication Required",
message: imgurError.failureReason ?? "You need to log in to Imgur to upload images with your account.",
preferredStyle: .alert
)

alert.addAction(UIAlertAction(title: "Log In", style: .default) { _ in
guard let viewController = self?.navigationController ?? self else { return }
ImgurAuthManager.shared.authenticate(from: viewController) { success in
if success {
// If authentication was successful, try submitting again
DispatchQueue.main.async {
self?.submit()
}
} else {
// Show failure message
let failureAlert = UIAlertController(
title: "Authentication Failed",
message: "Could not log in to Imgur. You can try again or switch to anonymous uploads in settings.",
alertActions: [.ok()]
)
viewController.present(failureAlert, animated: true)
}
}
})

alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
self?.focusInitialFirstResponder()
})

presenter?.present(alert, animated: true)
return
default:
break
}
}

// In case we're covered up by subsequent view controllers (console message about "detached view controllers"), aim for our navigation controller.
let presenter = self?.navigationController ?? self
Expand Down
131 changes: 127 additions & 4 deletions App/Composition/CompositionMenuTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,22 @@ import os
import Photos
import PSMenuItem
import UIKit
import AwfulSettings
import Foil
import ImgurAnonymousAPI

private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "CompositionMenuTree")

/// Can take over UIMenuController to show a tree of composition-related items on behalf of a text view.
final class CompositionMenuTree: NSObject {
// This class exists to expose the struct-defined menu to Objective-C and to act as an image picker delegate.

@FoilDefaultStorage(Settings.imgurUploadMode) private var imgurUploadMode

fileprivate var imgurUploadsEnabled: Bool {
return imgurUploadMode != .off
}

let textView: UITextView

/// The textView's class will have some responder chain methods swizzled.
Expand Down Expand Up @@ -75,6 +84,12 @@ final class CompositionMenuTree: NSObject {
}

func showImagePicker(_ sourceType: UIImagePickerController.SourceType) {
// Check if we need to authenticate with Imgur first
if ImgurAuthManager.shared.needsAuthentication {
authenticateWithImgur()
return
}

let picker = UIImagePickerController()
picker.sourceType = sourceType
let mediaType = UTType.image
Expand All @@ -92,6 +107,108 @@ final class CompositionMenuTree: NSObject {
textView.nearestViewController?.present(picker, animated: true, completion: nil)
}

private func authenticateWithImgur() {
guard let viewController = textView.nearestViewController else { return }

// Show an alert to explain why authentication is needed
let alert = UIAlertController(
title: "Imgur Authentication Required",
message: "You've enabled Imgur Account uploads in settings. To upload images with your account, you'll need to log in to Imgur.",
preferredStyle: .alert
)

alert.addAction(UIAlertAction(title: "Log In", style: .default) { _ in
// Show loading indicator
let loadingAlert = UIAlertController(
title: "Connecting to Imgur",
message: "Please wait...",
preferredStyle: .alert
)
viewController.present(loadingAlert, animated: true)

ImgurAuthManager.shared.authenticate(from: viewController) { success in
// Dismiss loading indicator
DispatchQueue.main.async {
loadingAlert.dismiss(animated: true) {
if success {
// If authentication was successful, continue with the upload
// Show a success message
let successAlert = UIAlertController(
title: "Successfully Logged In",
message: "You're now logged in to Imgur and can upload images with your account.",
preferredStyle: .alert
)

successAlert.addAction(UIAlertAction(title: "Continue", style: .default) { _ in
// Continue with image picker after successful authentication
self.showImagePicker(.photoLibrary)
})

viewController.present(successAlert, animated: true)
} else {
// Check if it's a rate limiting issue (check logs from ImgurAuthManager)
let isRateLimited = UserDefaults.standard.bool(forKey: ImgurAuthManager.DefaultsKeys.rateLimited)

if isRateLimited {
// Show specific rate limiting error
let rateLimitAlert = UIAlertController(
title: "Imgur Rate Limit Exceeded",
message: "Imgur's API is currently rate limited. You can try again later or use anonymous uploads for now.",
preferredStyle: .alert
)

rateLimitAlert.addAction(UIAlertAction(title: "Use Anonymous Uploads", style: .default) { _ in
// Switch to anonymous uploads for this session
self.imgurUploadMode = .anonymous
// Continue with image picker
self.showImagePicker(.photoLibrary)
})

rateLimitAlert.addAction(UIAlertAction(title: "Cancel", style: .cancel))

viewController.present(rateLimitAlert, animated: true)
} else {
// General authentication failure
let failureAlert = UIAlertController(
title: "Authentication Failed",
message: "Could not log in to Imgur. You can try again or choose anonymous uploads in settings.",
preferredStyle: .alert
)

failureAlert.addAction(UIAlertAction(title: "Try Again", style: .default) { _ in
// Try authentication again
self.authenticateWithImgur()
})

failureAlert.addAction(UIAlertAction(title: "Use Anonymous Upload", style: .default) { _ in
// Use anonymous uploads for this session
self.imgurUploadMode = .anonymous
// Continue with image picker
self.showImagePicker(.photoLibrary)
})

failureAlert.addAction(UIAlertAction(title: "Cancel", style: .cancel))

viewController.present(failureAlert, animated: true)
}
}
}
}
}
})

alert.addAction(UIAlertAction(title: "Use Anonymous Upload", style: .default) { _ in
// Use anonymous uploads just for this session
self.imgurUploadMode = .anonymous
// Show image picker with anonymous uploads
self.showImagePicker(.photoLibrary)
})

alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))

viewController.present(alert, animated: true)
}

func insertImage(_ image: UIImage, withAssetIdentifier assetID: String? = nil) {
// Inserting the image changes our font and text color, so save those now and restore those later.
let font = textView.font
Expand Down Expand Up @@ -202,11 +319,17 @@ fileprivate let rootItems = [
original line: MenuItem(title: "[img]", action: { $0.showSubmenu(imageItems) }),
*/
MenuItem(title: "[img]", action: { tree in
if UIPasteboard.general.coercedURL == nil {
linkifySelection(tree)
// If Imgur uploads are enabled in settings, show the full image submenu
// Otherwise, only allow pasting URLs
if tree.imgurUploadsEnabled {
tree.showSubmenu(imageItems)
} else {
if let textRange = tree.textView.selectedTextRange {
tree.textView.replace(textRange, withText:("[img]" + UIPasteboard.general.coercedURL!.absoluteString + "[/img]"))
if UIPasteboard.general.coercedURL == nil {
linkifySelection(tree)
} else {
if let textRange = tree.textView.selectedTextRange {
tree.textView.replace(textRange, withText:("[img]" + UIPasteboard.general.coercedURL!.absoluteString + "[/img]"))
}
}
}
}),
Expand Down
56 changes: 52 additions & 4 deletions App/Composition/UploadImageAttachments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,34 @@ import UIKit

private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UploadImageAttachments")

enum ImageUploadError: Error, LocalizedError {
case missingIdentifiedAsset
case authenticationRequired
case authenticationFailed

var errorDescription: String? {
switch self {
case .missingIdentifiedAsset:
return "Missing photo asset"
case .authenticationRequired:
return "Imgur Authentication Required"
case .authenticationFailed:
return "Imgur Authentication Failed"
}
}

var failureReason: String? {
switch self {
case .missingIdentifiedAsset:
return "Could not find the photo in your library."
case .authenticationRequired:
return "You need to log in to Imgur to upload images with your account."
case .authenticationFailed:
return "Could not log in to Imgur. Please try again or switch to anonymous uploads in settings."
}
}
}

/**
Replaces image attachments in richText with [img] tags by uploading the images anonymously to Imgur.

Expand All @@ -21,6 +49,22 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category:
func uploadImages(attachedTo richText: NSAttributedString, completion: @escaping (_ plainText: String?, _ error: Error?) -> Void) -> Progress {
let progress = Progress(totalUnitCount: 1)

// Check if we need authentication before proceeding
if ImgurAuthManager.shared.needsAuthentication {
DispatchQueue.main.async {
completion(nil, ImageUploadError.authenticationRequired)
}
return progress
}

// Check if token needs refresh
if ImgurAuthManager.shared.currentUploadMode == "Imgur Account" && ImgurAuthManager.shared.checkTokenExpiry() {
DispatchQueue.main.async {
completion(nil, ImageUploadError.authenticationRequired)
}
return progress
}

let localCopy = richText.copy() as! NSAttributedString
DispatchQueue.global(qos: DispatchQoS.QoSClass.default).async {
let tags = localCopy.imageTags
Expand All @@ -34,6 +78,14 @@ func uploadImages(attachedTo richText: NSAttributedString, completion: @escaping
let localerCopy = localCopy.mutableCopy() as! NSMutableAttributedString
let uploadProgress = uploadImages(fromSources: tags.map { $0.source }, completion: { (urls, error) in
if let error = error {
// If we get an authentication-related error from Imgur, clear the token and report it as auth error
if let imgurError = error as? ImgurUploader.Error, imgurError == .invalidClientID {
ImgurAuthManager.shared.logout() // Clear the token as it may be invalid
return DispatchQueue.main.async {
completion(nil, ImageUploadError.authenticationFailed)
}
}

return DispatchQueue.main.async {
completion(nil, error)
}
Expand Down Expand Up @@ -122,10 +174,6 @@ private func uploadImages(fromSources sources: [ImageTag.Source], completion: @e
return progress
}

enum ImageUploadError: Error {
case missingIdentifiedAsset
}

private struct ImageTag {
let range: NSRange
let size: CGSize
Expand Down
Loading