Skip to content
Open
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
20 changes: 3 additions & 17 deletions App/Composition/CompositionMenuTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,9 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category:
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.imageHostingProvider) private var imageHostingProvider
@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 @@ -331,19 +328,8 @@ fileprivate let rootItems = [
original line: MenuItem(title: "[img]", action: { $0.showSubmenu(imageItems) }),
*/
MenuItem(title: "[img]", action: { tree in
// 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 UIPasteboard.general.coercedURL == nil {
linkifySelection(tree)
} else {
if let textRange = tree.textView.selectedTextRange {
tree.textView.replace(textRange, withText:("[img]" + UIPasteboard.general.coercedURL!.absoluteString + "[/img]"))
}
}
}
// Image uploads are always enabled now (via Imgur or PostImages)
tree.showSubmenu(imageItems)
}),
MenuItem(title: "Format", action: { $0.showSubmenu(formattingItems) }),
MenuItem(title: "[video]", action: { tree in
Expand Down
61 changes: 61 additions & 0 deletions App/Composition/ImageUploadProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// ImageUploadProvider.swift
//
// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app

import Foundation
import Photos
import UIKit

/// Common protocol for image upload providers (Imgur, PostImages, etc.)
public protocol ImageUploadProvider {
/// Upload a UIImage
@discardableResult
func upload(_ image: UIImage, completion: @escaping (Result<ImageUploadResponse, Error>) -> Void) -> Progress

/// Upload a Photos asset
@discardableResult
func upload(_ asset: PHAsset, completion: @escaping (Result<ImageUploadResponse, Error>) -> Void) -> Progress

/// Upload from image picker info dictionary
@discardableResult
func upload(_ info: [UIImagePickerController.InfoKey: Any], completion: @escaping (Result<ImageUploadResponse, Error>) -> Void) -> Progress
}

/// Standardized response from any image upload provider
public struct ImageUploadResponse {
/// The URL of the uploaded image
public let imageURL: URL

/// Optional deletion URL/hash (not all providers support this)
public let deleteURL: URL?

/// Provider-specific metadata
public let metadata: [String: Any]

public init(imageURL: URL, deleteURL: URL? = nil, metadata: [String: Any] = [:]) {
self.imageURL = imageURL
self.deleteURL = deleteURL
self.metadata = metadata
}
}

/// Errors that can occur during image upload
public enum ImageUploadProviderError: LocalizedError {
case unsupportedImageFormat
case uploadFailed(String)
case invalidResponse
case providerUnavailable

public var errorDescription: String? {
switch self {
case .unsupportedImageFormat:
return "The image format is not supported"
case .uploadFailed(let message):
return "Upload failed: \(message)"
case .invalidResponse:
return "Invalid response from image hosting service"
case .providerUnavailable:
return "Image hosting service is unavailable"
}
}
}
27 changes: 5 additions & 22 deletions App/Composition/UploadImageAttachments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import AwfulSettings
import Foundation
import ImgurAnonymousAPI
import os
import Photos
import UIKit
Expand Down Expand Up @@ -50,15 +49,7 @@ func uploadImages(attachedTo richText: NSAttributedString, completion: @escaping
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() {
if ImageUploadManager.shared.needsAuthentication {
DispatchQueue.main.async {
completion(nil, ImageUploadError.authenticationRequired)
}
Expand All @@ -78,14 +69,6 @@ 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 @@ -132,10 +115,10 @@ private func uploadImages(fromSources sources: [ImageTag.Source], completion: @e

switch source {
case .image(let image):
ImgurUploader.shared.upload(image, completion: { result in
ImageUploadManager.shared.upload(image, completion: { result in
switch result {
case .success(let response):
uploadComplete(response.link, error: nil)
uploadComplete(response.imageURL, error: nil)
case .failure(let error):
logger.error("Could not upload UIImage: \(error)")
uploadComplete(nil, error: error)
Expand All @@ -149,10 +132,10 @@ private func uploadImages(fromSources sources: [ImageTag.Source], completion: @e
break
}

ImgurUploader.shared.upload(asset, completion: { result in
ImageUploadManager.shared.upload(asset, completion: { result in
switch result {
case .success(let response):
uploadComplete(response.link, error: nil)
uploadComplete(response.imageURL, error: nil)
case .failure(let error):
logger.error("Could not upload PHAsset: \(error)")
uploadComplete(nil, error: error)
Expand Down
224 changes: 224 additions & 0 deletions App/Extensions/ImageUploadManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
// ImageUploadManager.swift
//
// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app

import Foundation
import AwfulSettings
import ImgurAnonymousAPI
@_exported import PostImagesAPI
import Photos
import UIKit
import os

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

/// Manages image uploads using the configured provider (Imgur or PostImages)
public final class ImageUploadManager {

public static let shared = ImageUploadManager()

@FoilDefaultStorage(Settings.imageHostingProvider) private var imageHostingProvider
@FoilDefaultStorage(Settings.imgurUploadMode) private var imgurUploadMode

private init() {}

/// Get the current upload provider based on settings
private var currentProvider: ImageUploadProvider {
switch imageHostingProvider {
case .postImages:
logger.debug("Using PostImages provider")
return PostImagesUploadProviderAdapter()

case .imgur:
logger.debug("Using Imgur provider (mode: \(self.imgurUploadMode.rawValue))")
return ImgurUploadProviderAdapter()
}
}

/// Check if authentication is needed before uploading
public var needsAuthentication: Bool {
switch imageHostingProvider {
case .postImages:
return false
case .imgur:
return imgurUploadMode == .account && !ImgurAuthManager.shared.isAuthenticated
}
}

/// Upload a UIImage
@discardableResult
public func upload(_ image: UIImage, completion: @escaping (Result<ImageUploadResponse, Error>) -> Void) -> Progress {
if needsAuthentication {
let progress = Progress(totalUnitCount: 1)
progress.completedUnitCount = 1
DispatchQueue.main.async {
completion(.failure(ImageUploadProviderError.providerUnavailable))
}
return progress
}

return currentProvider.upload(image, completion: completion)
}

/// Upload a Photos asset
@discardableResult
public func upload(_ asset: PHAsset, completion: @escaping (Result<ImageUploadResponse, Error>) -> Void) -> Progress {
if needsAuthentication {
let progress = Progress(totalUnitCount: 1)
progress.completedUnitCount = 1
DispatchQueue.main.async {
completion(.failure(ImageUploadProviderError.providerUnavailable))
}
return progress
}

return currentProvider.upload(asset, completion: completion)
}

/// Upload from image picker info dictionary
@discardableResult
public func upload(_ info: [UIImagePickerController.InfoKey: Any], completion: @escaping (Result<ImageUploadResponse, Error>) -> Void) -> Progress {
if needsAuthentication {
let progress = Progress(totalUnitCount: 1)
progress.completedUnitCount = 1
DispatchQueue.main.async {
completion(.failure(ImageUploadProviderError.providerUnavailable))
}
return progress
}

return currentProvider.upload(info, completion: completion)
}
}

// MARK: - Provider Adapters

/// Adapter to make ImgurUploader conform to ImageUploadProvider
private class ImgurUploadProviderAdapter: ImageUploadProvider {

func upload(_ image: UIImage, completion: @escaping (Result<ImageUploadResponse, Error>) -> Void) -> Progress {
return ImgurUploader.shared.upload(image) { result in
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoding this singleton access seems weird. At minimum can we pass it in?

switch result {
case .success(let response):
let uploadResponse = ImageUploadResponse(
imageURL: response.link,
deleteURL: nil, // Could be constructed from delete hash if needed
metadata: [
"id": response.id,
"postLimit": response.postLimit as Any,
"rateLimit": response.rateLimit as Any
]
)
completion(.success(uploadResponse))
case .failure(let error):
completion(.failure(error))
}
}
}

func upload(_ asset: PHAsset, completion: @escaping (Result<ImageUploadResponse, Error>) -> Void) -> Progress {
return ImgurUploader.shared.upload(asset) { result in
switch result {
case .success(let response):
let uploadResponse = ImageUploadResponse(
imageURL: response.link,
deleteURL: nil,
metadata: [
"id": response.id,
"postLimit": response.postLimit as Any,
"rateLimit": response.rateLimit as Any
]
)
completion(.success(uploadResponse))
case .failure(let error):
completion(.failure(error))
}
}
}

func upload(_ info: [UIImagePickerController.InfoKey : Any], completion: @escaping (Result<ImageUploadResponse, Error>) -> Void) -> Progress {
return ImgurUploader.shared.upload(info) { result in
switch result {
case .success(let response):
let uploadResponse = ImageUploadResponse(
imageURL: response.link,
deleteURL: nil,
metadata: [
"id": response.id,
"postLimit": response.postLimit as Any,
"rateLimit": response.rateLimit as Any
]
)
completion(.success(uploadResponse))
case .failure(let error):
completion(.failure(error))
}
}
}
}

/// Adapter to make PostImagesUploader conform to ImageUploadProvider
private class PostImagesUploadProviderAdapter: ImageUploadProvider {

private let uploader = PostImagesUploader()

func upload(_ image: UIImage, completion: @escaping (Result<ImageUploadResponse, Error>) -> Void) -> Progress {
return uploader.upload(image) { result in
switch result {
case .success(let response):
let uploadResponse = ImageUploadResponse(
imageURL: response.imageURL,
deleteURL: nil, // PostImages doesn't provide deletion URLs
metadata: [
"directLink": response.directLink as Any,
"viewerLink": response.viewerLink as Any,
"thumbnailLink": response.thumbnailLink as Any
]
)
completion(.success(uploadResponse))
case .failure(let error):
completion(.failure(error))
}
}
}

func upload(_ asset: PHAsset, completion: @escaping (Result<ImageUploadResponse, Error>) -> Void) -> Progress {
return uploader.upload(asset) { result in
switch result {
case .success(let response):
let uploadResponse = ImageUploadResponse(
imageURL: response.imageURL,
deleteURL: nil,
metadata: [
"directLink": response.directLink as Any,
"viewerLink": response.viewerLink as Any,
"thumbnailLink": response.thumbnailLink as Any
]
)
completion(.success(uploadResponse))
case .failure(let error):
completion(.failure(error))
}
}
}

func upload(_ info: [UIImagePickerController.InfoKey : Any], completion: @escaping (Result<ImageUploadResponse, Error>) -> Void) -> Progress {
return uploader.upload(info) { result in
switch result {
case .success(let response):
let uploadResponse = ImageUploadResponse(
imageURL: response.imageURL,
deleteURL: nil,
metadata: [
"directLink": response.directLink as Any,
"viewerLink": response.viewerLink as Any,
"thumbnailLink": response.thumbnailLink as Any
]
)
completion(.success(uploadResponse))
case .failure(let error):
completion(.failure(error))
}
}
}
}
Loading