Compare commits

...

4 Commits

11 changed files with 205 additions and 43 deletions

View File

@ -7,11 +7,12 @@
// //
import Foundation import Foundation
import WebURL
public final class Status: StatusProtocol, Decodable { public final class Status: StatusProtocol, Decodable {
public let id: String public let id: String
public let uri: String public let uri: String
public let url: URL? public let url: WebURL?
public let account: Account public let account: Account
public let inReplyToID: String? public let inReplyToID: String?
public let inReplyToAccountID: String? public let inReplyToAccountID: String?

View File

@ -289,6 +289,7 @@
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; }; D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; }; D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; };
D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */; }; D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */; };
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */; };
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; }; D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; };
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */; }; D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */; };
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */; }; D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */; };
@ -674,6 +675,7 @@
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; }; D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; }; D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = "<group>"; }; D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = "<group>"; };
D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveToPhotosActivity.swift; sourceTree = "<group>"; };
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = "<group>"; }; D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = "<group>"; };
D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCollectionViewCell.swift; sourceTree = "<group>"; }; D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCollectionViewCell.swift; sourceTree = "<group>"; };
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListViewController.swift; sourceTree = "<group>"; }; D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListViewController.swift; sourceTree = "<group>"; };
@ -1299,6 +1301,7 @@
D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */, D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */,
D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */, D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */,
D6AEBB422321685E00E5038B /* OpenInSafariActivity.swift */, D6AEBB422321685E00E5038B /* OpenInSafariActivity.swift */,
D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */,
); );
path = Activities; path = Activities;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1974,6 +1977,7 @@
D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */, D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */,
D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */, D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */,
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */, D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */,
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */,
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */, D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
D68A76EE295369C7001DA1B3 /* AcknowledgementsView.swift in Sources */, D68A76EE295369C7001DA1B3 /* AcknowledgementsView.swift in Sources */,
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */, D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,

View File

@ -0,0 +1,71 @@
//
// SaveToPhotosActivity.swift
// Tusker
//
// Created by Shadowfacts on 1/2/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import UniformTypeIdentifiers
import Photos
class SaveToPhotosActivity: UIActivity {
override class var activityCategory: UIActivity.Category {
return .action
}
override var activityType: UIActivity.ActivityType? {
return .saveToPhotos
}
override var activityTitle: String? {
return "Save to Photos"
}
override var activityImage: UIImage? {
UIImage(systemName: "square.and.arrow.down")
}
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
return activityItems.contains(where: {
if let url = $0 as? URL,
let type = UTType(filenameExtension: url.pathExtension){
return type.conforms(to: .movie) || type.conforms(to: .video) || type.conforms(to: .image)
} else {
return false
}
})
}
private var url: URL?
private var type: UTType?
override func prepare(withActivityItems activityItems: [Any]) {
for case let url as URL in activityItems {
self.url = url
type = UTType(filenameExtension: url.pathExtension)!
}
}
override func perform() {
guard let url,
let type else {
return
}
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
guard case .authorized = status else {
return
}
PHPhotoLibrary.shared().performChanges {
if type.conforms(to: .movie) || type.conforms(to: .video) {
_ = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url)
} else {
_ = PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: url)
}
} completionHandler: { _, _ in
self.activityDidFinish(true)
}
}
}
}

View File

@ -11,5 +11,6 @@ import UIKit
extension UIActivity.ActivityType { extension UIActivity.ActivityType {
static let openInSafari = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).open_in_safari") static let openInSafari = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).open_in_safari")
static let saveToPhotos = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).save_to_photos")
} }

View File

@ -10,6 +10,7 @@
import Foundation import Foundation
import CoreData import CoreData
import Pachyderm import Pachyderm
import WebURLFoundationExtras
@objc(StatusMO) @objc(StatusMO)
public final class StatusMO: NSManagedObject, StatusProtocol { public final class StatusMO: NSManagedObject, StatusProtocol {
@ -127,7 +128,7 @@ extension StatusMO {
self.sensitive = status.sensitive self.sensitive = status.sensitive
self.spoilerText = status.spoilerText self.spoilerText = status.spoilerText
self.uri = status.uri self.uri = status.uri
self.url = status.url self.url = status.url != nil ? URL(status.url!) : nil
self.visibility = status.visibility self.visibility = status.visibility
self.poll = status.poll self.poll = status.poll
self.localOnly = status.localOnly ?? false self.localOnly = status.localOnly ?? false

View File

@ -211,7 +211,10 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
} }
func logoutCurrent() { func logoutCurrent() {
LocalData.shared.removeAccount(LocalData.shared.getMostRecentAccount()!) guard let account = window?.windowScene?.session.mastodonController?.accountInfo else {
return
}
LocalData.shared.removeAccount(account)
if LocalData.shared.onboardingComplete { if LocalData.shared.onboardingComplete {
activateAccount(LocalData.shared.accounts.first!, animated: false) activateAccount(LocalData.shared.accounts.first!, animated: false)
} else { } else {

View File

@ -15,7 +15,7 @@ class AccountListViewController: UIViewController, CollectionViewController {
private let accountIDs: [String] private let accountIDs: [String]
var collectionView: UICollectionView! { var collectionView: UICollectionView! {
view as! UICollectionView view as? UICollectionView
} }
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!

View File

@ -31,13 +31,20 @@ class LargeImageImageContentView: UIImageView, LargeImageContentView {
var animationImage: UIImage? { image! } var animationImage: UIImage? { image! }
var activityItemsForSharing: [Any] { var activityItemsForSharing: [Any] {
[image!] guard let data else {
return []
}
return [ImageActivityItemSource(data: data, url: url, image: image)]
} }
weak var owner: LargeImageViewController? weak var owner: LargeImageViewController?
private var sourceData: Data? private let url: URL
private let data: Data?
init(image: UIImage) { init(url: URL, data: Data?, image: UIImage) {
self.url = url
self.data = data
super.init(image: image) super.init(image: image)
contentMode = .scaleAspectFit contentMode = .scaleAspectFit
@ -79,7 +86,7 @@ class LargeImageImageContentView: UIImageView, LargeImageContentView {
} }
func grayscaleStateChanged() { func grayscaleStateChanged() {
guard let data = sourceData else { guard let data else {
return return
} }
@ -108,12 +115,15 @@ extension LargeImageImageContentView: ImageAnalysisInteractionDelegate {
class LargeImageGifContentView: GIFImageView, LargeImageContentView { class LargeImageGifContentView: GIFImageView, LargeImageContentView {
var animationImage: UIImage? { image } var animationImage: UIImage? { image }
var activityItemsForSharing: [Any] { var activityItemsForSharing: [Any] {
// todo: should gifs share the data? [ImageActivityItemSource(data: gifController!.gifData, url: url, image: image)]
[image].compactMap { $0 }
} }
weak var owner: LargeImageViewController? weak var owner: LargeImageViewController?
init(gifController: GIFController) { private let url: URL
init(url: URL, gifController: GIFController) {
self.url = url
super.init(image: gifController.lastFrame?.image) super.init(image: gifController.lastFrame?.image)
contentMode = .scaleAspectFit contentMode = .scaleAspectFit
@ -138,22 +148,22 @@ class LargeImageGifContentView: GIFImageView, LargeImageContentView {
class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView { class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
private(set) var animationImage: UIImage? private(set) var animationImage: UIImage?
var activityItemsForSharing: [Any] { var activityItemsForSharing: [Any] {
// todo: what should we share for gifvs? [GifvActivityItemSource(asset: asset, attachment: attachment)]
// some SO posts indicate that just sharing a URL to the video should work, but that may need to be a local URL?
[]
} }
weak var owner: LargeImageViewController? weak var owner: LargeImageViewController?
private let attachment: Attachment
private let asset: AVURLAsset private let asset: AVURLAsset
private var videoSize: CGSize? private var videoSize: CGSize?
override var intrinsicContentSize: CGSize { override var intrinsicContentSize: CGSize {
videoSize ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric) videoSize ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
} }
init(attachment: Attachment, source: UIImageView) { init(attachment: Attachment, source: UIImageView) {
precondition(attachment.kind == .gifv) precondition(attachment.kind == .gifv)
self.attachment = attachment
self.asset = AVURLAsset(url: attachment.url) self.asset = AVURLAsset(url: attachment.url)
super.init(asset: asset, gravity: .resizeAspect) super.init(asset: asset, gravity: .resizeAspect)
@ -184,4 +194,78 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
func grayscaleStateChanged() { func grayscaleStateChanged() {
// no-op, GifvAttachmentView observes the grayscale state itself // no-op, GifvAttachmentView observes the grayscale state itself
} }
}
fileprivate class ImageActivityItemSource: NSObject, UIActivityItemSource {
let data: Data
let url: URL
let image: UIImage?
init(data: Data, url: URL, image: UIImage?) {
self.data = data
self.url = url
self.image = image
}
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
return url
}
func activityViewController(_ activityViewController: UIActivityViewController, thumbnailImageForActivityType activityType: UIActivity.ActivityType?, suggestedSize size: CGSize) -> UIImage? {
return image
}
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
do {
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent)
try data.write(to: tempURL)
return tempURL
} catch {
return nil
}
}
func activityViewController(_ activityViewController: UIActivityViewController, dataTypeIdentifierForActivityType activityType: UIActivity.ActivityType?) -> String {
return (UTType(filenameExtension: url.pathExtension) ?? .image).identifier
}
}
fileprivate class GifvActivityItemSource: NSObject, UIActivityItemSource {
let asset: AVAsset
let attachment: Attachment
init(asset: AVAsset, attachment: Attachment) {
self.asset = asset
self.attachment = attachment
}
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
return attachment.url
}
func activityViewController(_ activityViewController: UIActivityViewController, thumbnailImageForActivityType activityType: UIActivity.ActivityType?, suggestedSize size: CGSize) -> UIImage? {
let generator = AVAssetImageGenerator(asset: self.asset)
generator.appliesPreferredTrackTransform = true
if let image = try? generator.copyCGImage(at: .zero, actualTime: nil) {
return UIImage(cgImage: image)
} else {
return nil
}
}
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
do {
let data = try Data(contentsOf: attachment.url)
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(attachment.url.lastPathComponent)
try data.write(to: tempURL)
return tempURL
} catch {
return nil
}
}
func activityViewController(_ activityViewController: UIActivityViewController, dataTypeIdentifierForActivityType activityType: UIActivity.ActivityType?) -> String {
return (UTType(filenameExtension: attachment.url.pathExtension) ?? .video).identifier
}
} }

View File

@ -381,7 +381,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
} }
@IBAction func sharePressed(_ sender: Any) { @IBAction func sharePressed(_ sender: Any) {
let activityVC = UIActivityViewController(activityItems: contentView.activityItemsForSharing, applicationActivities: nil) let activityVC = UIActivityViewController(activityItems: contentView.activityItemsForSharing, applicationActivities: [SaveToPhotosActivity()])
activityVC.popoverPresentationController?.sourceView = shareImage activityVC.popoverPresentationController?.sourceView = shareImage
present(activityVC, animated: true) present(activityVC, animated: true)
} }

View File

@ -135,12 +135,12 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
// is it possible for the source view's gif controller to have different data than we just got? // is it possible for the source view's gif controller to have different data than we just got?
// should this be a property set by the animation controller instead? // should this be a property set by the animation controller instead?
let gifController = (animationSourceView as? GIFImageView)?.gifController ?? GIFController(gifData: data) let gifController = (animationSourceView as? GIFImageView)?.gifController ?? GIFController(gifData: data)
content = LargeImageGifContentView(gifController: gifController) content = LargeImageGifContentView(url: url, gifController: gifController)
} else { } else {
if let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) { if let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) {
content = LargeImageImageContentView(image: transformedImage) content = LargeImageImageContentView(url: url, data: data, image: transformedImage)
} else { } else {
content = LargeImageImageContentView(image: image) content = LargeImageImageContentView(url: url, data: data, image: image)
} }
} }
@ -167,7 +167,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: source) { let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: source) {
image = grayscale image = grayscale
} }
setContent(LargeImageImageContentView(image: image)) setContent(LargeImageImageContentView(url: url, data: nil, image: image))
} }
} }

View File

@ -58,30 +58,27 @@ extension ToastConfiguration {
// TODO: this is a bizarre place to do this, but code path covers basically all errors // TODO: this is a bizarre place to do this, but code path covers basically all errors
switch error.type { switch error.type {
case .invalidRequest, .invalidResponse, .invalidModel(_), .mastodonError(_): case .invalidRequest, .invalidResponse, .invalidModel(_), .mastodonError(_):
SentrySDK.capture(error: error) { scope in let event = Event(error: error)
scope.setFingerprint([String(describing: error)]) event.message = SentryMessage(formatted: "\(title): \(error)")
let crumb = Breadcrumb(level: .error, category: "error") event.tags = [
crumb.message = title "request_method": error.requestMethod.name,
crumb.data = [ "request_endpoint": error.requestEndpoint.description,
"request_method": error.requestMethod.name, ]
"request_endpoint": error.requestEndpoint.description, switch error.type {
] case .invalidRequest:
switch error.type { event.tags!["error_type"] = "invalid_request"
case .invalidRequest: case .invalidResponse:
crumb.data!["error_type"] = "invalid_request" event.tags!["error_type"] = "invalid_response"
case .invalidResponse: case .invalidModel(let error):
crumb.data!["error_type"] = "invalid_response" event.tags!["error_type"] = "invalid_model"
case .invalidModel(let error): event.tags!["underlying_error"] = String(describing: error)
crumb.data!["error_type"] = "invalid_model" case .mastodonError(let error):
crumb.data!["underlying_error"] = String(describing: error) event.tags!["error_type"] = "mastodon_error"
case .mastodonError(let error): event.tags!["underlying_error"] = error
crumb.data!["error_type"] = "mastodon_error" default:
crumb.data!["underlying_error"] = error break
default:
break
}
scope.add(crumb)
} }
SentrySDK.capture(event: event)
default: default:
break break
} }