Compare commits

...

31 Commits

Author SHA1 Message Date
Shadowfacts 5ce9892a9b Bump build number and update changelog 2024-12-16 20:17:44 -05:00
Shadowfacts 54376ac585 Handle empty urls in OptionalURLDecoder
Closes #553
2024-12-16 19:10:23 -05:00
Shadowfacts 26c483fc9a Bump build number and update changelog 2024-12-15 20:28:08 -05:00
Shadowfacts a68d2ce952 Fix compiling for visionOS 2024-12-15 17:07:38 -05:00
Shadowfacts adaf8dc217 Replace WebURL with URL.ParseStrategy
Closes #170
2024-12-15 14:20:48 -05:00
Shadowfacts 572c5a0824 Fix NotificationGroupTests not compiling 2024-12-15 13:52:26 -05:00
Shadowfacts e469d207b4 Make "no alt" badge all caps 2024-12-15 13:31:03 -05:00
Shadowfacts 82ec120871 Include rate limit reset date in error message
Closes #548
2024-12-15 13:27:09 -05:00
Shadowfacts 242c60d74d Workaround for tab bar content VC not being in responder chain almost ever
Closes #544, #179
2024-12-15 13:18:47 -05:00
Shadowfacts 20692b0630 Fix links in profile field values not at the beginning of the string not being tappable
Fixes #501
2024-12-07 13:00:54 -05:00
Shadowfacts 9990d50e3e Revert "Use text view for profile field value view"
This reverts commit c88076eec0.

Closes #521
2024-12-07 13:00:13 -05:00
Shadowfacts 670047af6f Fix potential race between adding notification to NSManagedObjectContext and displaying VC 2024-11-26 23:26:35 -05:00
Shadowfacts e8a492ef7d Fix potential crash with invalid attachment aspect ratio 2024-11-26 23:17:52 -05:00
Shadowfacts 583d9b97dd Bump build number and update changelog 2024-11-26 23:10:39 -05:00
Shadowfacts 88176fe599 Try to fix Live Text control weridness during interactive gallery dismissal 2024-11-26 20:06:23 -05:00
Shadowfacts 19c3008c8f Better error for when emoji URL decoding fails
Closes #549
2024-11-26 20:06:23 -05:00
Shadowfacts 51f9f421b8 Fix hang when using switching accounts on iPadOS 18
Closes #550
2024-11-26 20:06:23 -05:00
Shadowfacts b700e17d7e Hide video controls in gallery if loading fails
Closes #546
2024-11-26 20:06:23 -05:00
Shadowfacts dc01804359 Don't use snapshot for gallery present/dismiss transition of non-static content 2024-11-25 21:05:20 -05:00
Shadowfacts a5066140fd Fix potential crash during gifv playback
I don't know how this is possible, but it evidently is
2024-11-25 20:54:35 -05:00
Shadowfacts 351efe4b58 Fix gallery content scrolling unnecessarily in certain circumstances 2024-11-25 20:40:13 -05:00
Shadowfacts c716f03784 More gallery transition tweaks 2024-11-25 19:07:05 -05:00
Shadowfacts fa828a5eae Gallery dismiss interaction fixes 2024-11-25 18:38:07 -05:00
Shadowfacts 56d12295ba Don't dismiss gallery if pan distance/velocity is low
See #520
2024-11-24 23:58:39 -05:00
Shadowfacts a442197adf Improve gallery dismiss transition when source view is relatively small compared to content
See #520
2024-11-24 19:26:09 -05:00
Shadowfacts a99fb7f0b0 Improve gallery transitions when there is something displaying on top of the source view
See #520
2024-11-24 19:26:06 -05:00
Shadowfacts f44dae632c Improve gallery transitions when source/dest aspect ratio don't match
See #520
2024-11-24 18:19:59 -05:00
Shadowfacts 0dcb67c44e Fix gallery dismiss animation not working when the window's origin is not the screen origin
sourceView is inside to.view, so to.view needs to be added as a subview
of container before we call convert(_:from:)
2024-11-23 10:59:51 -05:00
Shadowfacts e869fdc38f Disallow more interactions on non-browsable public instance timeline 2024-11-23 10:52:17 -05:00
Shadowfacts 5c86feccb9 Move content VCs to GalleryVC package 2024-11-21 19:28:55 -05:00
Shadowfacts 01cf597b5d Account for bidi text in combined display/username label 2024-10-22 17:51:58 -04:00
78 changed files with 1017 additions and 615 deletions

View File

@ -1,5 +1,28 @@
# Changelog
## 2024.5 (139)
Bugfixes:
- Fix error decoding certain posts
## 2024.5 (138)
Bugfixes:
- Fix potential crash when displaying certain attachments
- Fix potential crash due to race condition when opening push notification in app
- Fix misaligned text between profile field values/labels
- Fix rate limited error message not including reset timestamp
- iPadOS/macOS: Fix Cmd+R shortcut not working
## 2024.5 (137)
Features/Improvements:
- Improve gallery presentation/dismissal transitions
Bugfixes:
- Account for bidirectional text in display names
- Fix crash when playing back gifv
- Fix gallery controls not hiding if video loading fails
- iPadOS: Fix incorrect gallery dismiss animation on non-fullscreen windows
- iPadOS: Fix hang when switching accounts
## 2024.4 (136)
Features/Improvements:
- Import image description when adding attachments from Photos if possible

View File

@ -14,7 +14,6 @@ import OSLog
import Pachyderm
import Intents
import HTMLStreamer
import WebURL
import UIKit
import TuskerPreferences
@ -238,8 +237,7 @@ class NotificationService: UNNotificationServiceExtension {
for match in emojiRegex.matches(in: content.body, range: NSRange(location: 0, length: content.body.utf16.count)).reversed() {
let emojiName = (content.body as NSString).substring(with: match.range(at: 1))
guard let emoji = notification.status?.emojis.first(where: { $0.shortcode == emojiName }),
let url = URL(emoji.url),
let (data, _) = try? await URLSession.shared.data(from: url),
let (data, _) = try? await URLSession.shared.data(from: emoji.url),
let image = UIImage(data: data) else {
continue
}
@ -368,17 +366,7 @@ private func decodeBase64URL(_ s: String) -> Data? {
// copied from HTMLConverter.Callbacks, blergh
private struct HTMLCallbacks: HTMLConversionCallbacks {
static func makeURL(string: String) -> URL? {
// Converting WebURL to URL is a small but non-trivial expense (since it works by
// serializing the WebURL as a string and then having Foundation parse it again),
// so, if available, use the system parser which doesn't require another round trip.
if let url = try? URL.ParseStrategy().parse(string) {
url
} else if let web = WebURL(string),
let url = URL(web) {
url
} else {
nil
}
try? URL.ParseStrategy().parse(string)
}
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {

View File

@ -14,11 +14,15 @@ let package = Package(
name: "GalleryVC",
targets: ["GalleryVC"]),
],
dependencies: [
.package(path: "../TuskerComponents"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "GalleryVC",
dependencies: ["TuskerComponents"],
swiftSettings: [
.swiftLanguageMode(.v5)
]),

View File

@ -1,15 +1,13 @@
//
// FallbackGalleryContentViewController.swift
// Tusker
// GalleryVC
//
// Created by Shadowfacts on 3/18/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import GalleryVC
import QuickLook
import Pachyderm
private class FallbackGalleryContentViewController: QLPreviewController {
private let previewItem = GalleryPreviewItem()
@ -52,40 +50,40 @@ extension FallbackGalleryContentViewController: QLPreviewControllerDataSource {
}
}
class FallbackGalleryNavigationController: UINavigationController, GalleryContentViewController {
init(url: URL) {
public class FallbackGalleryNavigationController: UINavigationController, GalleryContentViewController {
public init(url: URL) {
super.init(nibName: nil, bundle: nil)
self.viewControllers = [FallbackGalleryContentViewController(url: url)]
}
override func viewDidLoad() {
public override func viewDidLoad() {
super.viewDidLoad()
container?.disableGalleryScrollAndZoom()
}
required init?(coder aDecoder: NSCoder) {
public required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: GalleryContentViewController
weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
public weak var container: (any GalleryContentViewControllerContainer)?
var contentSize: CGSize {
public var contentSize: CGSize {
.zero
}
var activityItemsForSharing: [Any] {
public var activityItemsForSharing: [Any] {
[]
}
var caption: String? {
public var caption: String? {
nil
}
var canAnimateFromSourceView: Bool {
false
public var presentationAnimation: GalleryContentPresentationAnimation {
.fade
}
}

View File

@ -1,22 +1,22 @@
//
// ImageGalleryContentViewController.swift
// Tusker
// GalleryVC
//
// Created by Shadowfacts on 3/17/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import GalleryVC
import Pachyderm
import TuskerComponents
@preconcurrency import VisionKit
class ImageGalleryContentViewController: UIViewController, GalleryContentViewController {
let url: URL
let caption: String?
let originalData: Data?
let image: UIImage
open class ImageGalleryContentViewController: UIViewController, GalleryContentViewController {
public let caption: String?
public var image: UIImage {
didSet {
imageView?.image = image
}
}
let gifController: GIFController?
private var imageView: GIFImageView!
@ -27,12 +27,8 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
@available(iOS 16.0, macCatalyst 17.0, *)
private var analysisInteraction: ImageAnalysisInteraction? { _analysisInteraction as? ImageAnalysisInteraction }
private var isGrayscale = false
init(url: URL, caption: String?, originalData: Data?, image: UIImage, gifController: GIFController?) {
self.url = url
public init(image: UIImage, caption: String?, gifController: GIFController?) {
self.caption = caption
self.originalData = originalData
self.image = image
self.gifController = gifController
@ -41,21 +37,14 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
preferredContentSize = image.size
}
required init?(coder: NSCoder) {
public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
public override func viewDidLoad() {
super.viewDidLoad()
isGrayscale = Preferences.shared.grayscaleImages
let maybeGrayscaleImage = if isGrayscale {
ImageGrayscalifier.convert(url: url, image: image) ?? image
} else {
image
}
imageView = GIFImageView(image: maybeGrayscaleImage)
imageView = GIFImageView(image: image)
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFill
imageView.isUserInteractionEnabled = true
@ -86,11 +75,9 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
}
}
}
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
override func viewWillAppear(_ animated: Bool) {
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if let gifController {
@ -98,37 +85,23 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
}
}
@objc private func preferencesChanged() {
if isGrayscale != Preferences.shared.grayscaleImages {
isGrayscale = Preferences.shared.grayscaleImages
let image = if isGrayscale {
ImageGrayscalifier.convert(url: url, image: image)
} else {
image
}
if let image {
imageView.image = image
}
}
}
// MARK: GalleryContentViewController
weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
public weak var container: (any GalleryContentViewControllerContainer)?
var contentSize: CGSize {
public var contentSize: CGSize {
image.size
}
var activityItemsForSharing: [Any] {
if let data = originalData ?? image.pngData() {
return [ImageActivityItemSource(data: data, url: url, image: image)]
} else {
return []
}
open var activityItemsForSharing: [Any] {
return [image]
}
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
public var presentationAnimation: GalleryContentPresentationAnimation {
gifController != nil ? .fromSourceViewWithoutSnapshot : .fromSourceView
}
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
if #available(iOS 16.0, macCatalyst 17.0, *),
let analysisInteraction {
analysisInteraction.setSupplementaryInterfaceHidden(!visible, animated: animated)
@ -138,7 +111,7 @@ class ImageGalleryContentViewController: UIViewController, GalleryContentViewCon
@available(iOS 16.0, macCatalyst 17.0, *)
extension ImageGalleryContentViewController: ImageAnalysisInteractionDelegate {
func interaction(_ interaction: ImageAnalysisInteraction, shouldBeginAt point: CGPoint, for interactionType: ImageAnalysisInteraction.InteractionTypes) -> Bool {
public func interaction(_ interaction: ImageAnalysisInteraction, shouldBeginAt point: CGPoint, for interactionType: ImageAnalysisInteraction.InteractionTypes) -> Bool {
return container?.galleryControlsVisible ?? true
}
}

View File

@ -7,43 +7,42 @@
//
import UIKit
import GalleryVC
class LoadingGalleryContentViewController: UIViewController, GalleryContentViewController {
public class LoadingGalleryContentViewController: UIViewController, GalleryContentViewController {
private let fallbackCaption: String?
private let provider: () async -> (any GalleryContentViewController)?
private var wrapped: (any GalleryContentViewController)!
weak var container: GalleryContentViewControllerContainer?
public weak var container: GalleryContentViewControllerContainer?
var contentSize: CGSize {
public var contentSize: CGSize {
wrapped?.contentSize ?? .zero
}
var activityItemsForSharing: [Any] {
public var activityItemsForSharing: [Any] {
wrapped?.activityItemsForSharing ?? []
}
var caption: String? {
public var caption: String? {
wrapped?.caption ?? fallbackCaption
}
var canAnimateFromSourceView: Bool {
wrapped?.canAnimateFromSourceView ?? true
public var presentationAnimation: GalleryContentPresentationAnimation {
wrapped?.presentationAnimation ?? .fade
}
init(caption: String?, provider: @escaping () async -> (any GalleryContentViewController)?) {
public init(caption: String?, provider: @escaping () async -> (any GalleryContentViewController)?) {
self.fallbackCaption = caption
self.provider = provider
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
public override func viewDidLoad() {
super.viewDidLoad()
container?.setGalleryContentLoading(true)
@ -81,7 +80,7 @@ class LoadingGalleryContentViewController: UIViewController, GalleryContentViewC
let label = UILabel()
label.text = "Error Loading"
label.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)!
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .title1).withSymbolicTraits(.traitBold)!, size: 0)
label.textColor = .secondaryLabel
label.adjustsFontForContentSizeCategory = true
@ -102,15 +101,15 @@ class LoadingGalleryContentViewController: UIViewController, GalleryContentViewC
])
}
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
wrapped?.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
}
func galleryContentDidAppear() {
public func galleryContentDidAppear() {
wrapped?.galleryContentDidAppear()
}
func galleryContentWillDisappear() {
public func galleryContentWillDisappear() {
wrapped?.galleryContentWillDisappear()
}

View File

@ -1,6 +1,6 @@
//
// VideoControlsViewController.swift
// Tusker
// GalleryVC
//
// Created by Shadowfacts on 3/21/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
@ -19,27 +19,35 @@ class VideoControlsViewController: UIViewController {
private let player: AVPlayer
private lazy var muteButton = MuteButton().configure {
$0.addTarget(self, action: #selector(muteButtonPressed), for: .touchUpInside)
$0.setMuted(false, animated: false)
}
private lazy var muteButton: MuteButton = {
let button = MuteButton()
button.addTarget(self, action: #selector(muteButtonPressed), for: .touchUpInside)
button.setMuted(false, animated: false)
return button
}()
private let timestampLabel = UILabel().configure {
$0.text = "0:00"
$0.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
}
private let timestampLabel: UILabel = {
let label = UILabel()
label.text = "0:00"
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
return label
}()
private lazy var scrubbingControl = VideoScrubbingControl().configure {
$0.heightAnchor.constraint(equalToConstant: 44).isActive = true
$0.addTarget(self, action: #selector(scrubbingStarted), for: .editingDidBegin)
$0.addTarget(self, action: #selector(scrubbingChanged), for: .editingChanged)
$0.addTarget(self, action: #selector(scrubbingEnded), for: .editingDidEnd)
}
private lazy var scrubbingControl: VideoScrubbingControl = {
let control = VideoScrubbingControl()
control.heightAnchor.constraint(equalToConstant: 44).isActive = true
control.addTarget(self, action: #selector(scrubbingStarted), for: .editingDidBegin)
control.addTarget(self, action: #selector(scrubbingChanged), for: .editingChanged)
control.addTarget(self, action: #selector(scrubbingEnded), for: .editingDidEnd)
return control
}()
private let timeRemainingLabel = UILabel().configure {
$0.text = "-0:00"
$0.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
}
private let timeRemainingLabel: UILabel = {
let label = UILabel()
label.text = "-0:00"
label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .monospacedDigitSystemFont(ofSize: 13, weight: .regular))
return label
}()
private lazy var optionsButton = MenuButton { [unowned self] in
let imageName: String
@ -70,17 +78,19 @@ class VideoControlsViewController: UIViewController {
return UIMenu(children: [speedMenu])
}
private lazy var hStack = UIStackView(arrangedSubviews: [
muteButton,
timestampLabel,
scrubbingControl,
timeRemainingLabel,
optionsButton,
]).configure {
$0.axis = .horizontal
$0.spacing = 8
$0.alignment = .center
}
private lazy var hStack: UIStackView = {
let stack = UIStackView(arrangedSubviews: [
muteButton,
timestampLabel,
scrubbingControl,
timeRemainingLabel,
optionsButton,
])
stack.axis = .horizontal
stack.spacing = 8
stack.alignment = .center
return stack
}()
private var timestampObserverToken: Any?
private var scrubberObserverToken: Any?

View File

@ -1,68 +1,53 @@
//
// VideoGalleryContentViewController.swift
// Tusker
// GalleryVC
//
// Created by Shadowfacts on 3/19/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import GalleryVC
import AVFoundation
import CoreImage
class VideoGalleryContentViewController: UIViewController, GalleryContentViewController {
private let url: URL
let caption: String?
private var item: AVPlayerItem
let player: AVPlayer
private var isGrayscale: Bool
open class VideoGalleryContentViewController: UIViewController, GalleryContentViewController {
public let url: URL
public let caption: String?
public private(set) var item: AVPlayerItem
public let player: AVPlayer
private var presentationSizeObservation: NSKeyValueObservation?
private var statusObservation: NSKeyValueObservation?
private var rateObservation: NSKeyValueObservation?
private var isFirstAppearance = true
private var hideControlsWorkItem: DispatchWorkItem?
private var audioSessionToken: AudioSessionCoordinator.Token?
private var isShowingError = false
init(url: URL, caption: String?) {
public init(url: URL, caption: String?) {
self.url = url
self.caption = caption
self.isGrayscale = Preferences.shared.grayscaleImages
let asset = AVAsset(url: url)
self.item = VideoGalleryContentViewController.createItem(asset: asset)
self.item = Self.createItem(asset: asset)
self.player = AVPlayer(playerItem: item)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
public required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private static func createItem(asset: AVAsset) -> AVPlayerItem {
let item = AVPlayerItem(asset: asset)
if Preferences.shared.grayscaleImages {
#if os(visionOS)
#warning("Use async AVVideoComposition CIFilter initializer")
#else
let filter = CIFilter(name: "CIColorMonochrome")!
filter.setValue(CIColor(red: 0.85, green: 0.85, blue: 0.85), forKey: "inputColor")
filter.setValue(1.0, forKey: "inputIntensity")
item.videoComposition = AVVideoComposition(asset: asset, applyingCIFiltersWithHandler: { request in
filter.setValue(request.sourceImage, forKey: "inputImage")
request.finish(with: filter.outputImage!, context: nil)
})
#endif
}
return item
open class func createItem(asset: AVAsset) -> AVPlayerItem {
return AVPlayerItem(asset: asset)
}
override func viewDidLoad() {
public func replaceCurrentItem(with item: AVPlayerItem) {
self.item = item
player.replaceCurrentItem(with: item)
updateItemObservations()
}
public override func viewDidLoad() {
super.viewDidLoad()
container?.setGalleryContentLoading(true)
@ -87,19 +72,17 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
scheduleControlsHide()
}
})
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
private func updateItemObservations() {
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] item, _ in
MainActor.runUnsafely {
MainActor.assumeIsolated {
self.preferredContentSize = item.presentationSize
self.container?.galleryContentChanged()
}
})
statusObservation = item.observe(\.status, changeHandler: { [unowned self] item, _ in
MainActor.runUnsafely {
MainActor.assumeIsolated {
if item.status == .readyToPlay {
self.container?.setGalleryContentLoading(false)
self.statusObservation = nil
@ -108,19 +91,22 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
self.container?.setGalleryContentLoading(false)
self.showErrorView(error)
self.statusObservation = nil
self.overlayVC.setVisible(false)
}
}
})
}
private func showErrorView(_ error: any Error) {
isShowingError = true
let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!)
image.tintColor = .secondaryLabel
image.contentMode = .scaleAspectFit
let label = UILabel()
label.text = "Error Loading"
label.font = .preferredFont(forTextStyle: .title1).withTraits(.traitBold)!
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .title1).withSymbolicTraits(.traitBold)!, size: 0)
label.textColor = .secondaryLabel
label.adjustsFontForContentSizeCategory = true
@ -148,22 +134,9 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
])
}
@objc private func preferencesChanged() {
if isGrayscale != Preferences.shared.grayscaleImages {
let isPlaying = player.rate > 0
isGrayscale = Preferences.shared.grayscaleImages
item = VideoGalleryContentViewController.createItem(asset: item.asset)
player.replaceCurrentItem(with: item)
updateItemObservations()
if isPlaying {
player.play()
}
}
}
private func scheduleControlsHide() {
hideControlsWorkItem = DispatchWorkItem { [weak self] in
MainActor.runUnsafely {
MainActor.assumeIsolated {
guard let self,
let container = self.container,
container.galleryControlsVisible else {
@ -177,25 +150,32 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
// MARK: GalleryContentViewController
weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
public weak var container: (any GalleryVC.GalleryContentViewControllerContainer)?
var contentSize: CGSize {
public var contentSize: CGSize {
item.presentationSize
}
var activityItemsForSharing: [Any] {
[VideoActivityItemSource(asset: item.asset, url: url)]
open var activityItemsForSharing: [Any] {
// [VideoActivityItemSource(asset: item.asset, url: url)]
[]
}
public var presentationAnimation: GalleryContentPresentationAnimation {
isShowingError ? .fade : .fromSourceViewWithoutSnapshot
}
private lazy var overlayVC = VideoOverlayViewController(player: player)
var contentOverlayAccessoryViewController: UIViewController? {
public var contentOverlayAccessoryViewController: UIViewController? {
overlayVC
}
private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player)
public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player)
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
overlayVC.setVisible(visible)
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
if !isShowingError {
overlayVC.setVisible(visible)
}
if !visible {
hideControlsWorkItem?.cancel()
@ -205,25 +185,11 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
}
}
func galleryContentDidAppear() {
let wasFirstAppearance = isFirstAppearance
isFirstAppearance = false
audioSessionToken = AudioSessionCoordinator.shared.beginPlayback(mode: .video) {
if wasFirstAppearance {
DispatchQueue.main.async {
self.player.play()
}
}
}
open func galleryContentDidAppear() {
}
func galleryContentWillDisappear() {
open func galleryContentWillDisappear() {
player.pause()
if let audioSessionToken {
AudioSessionCoordinator.shared.endPlayback(token: audioSessionToken)
}
}
}
@ -252,9 +218,9 @@ private class PlayerView: UIView {
playerLayer.player = player
playerLayer.videoGravity = .resizeAspect
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] _, _ in
MainActor.runUnsafely {
self.invalidateIntrinsicContentSize()
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [weak self] _, _ in
MainActor.assumeIsolated {
self?.invalidateIntrinsicContentSize()
}
})
}

View File

@ -1,6 +1,6 @@
//
// VideoOverlayViewController.swift
// Tusker
// GalleryVC
//
// Created by Shadowfacts on 3/26/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
@ -79,7 +79,7 @@ class VideoOverlayViewController: UIViewController {
])
rateObservation = player.observe(\.rate, changeHandler: { player, _ in
MainActor.runUnsafely {
MainActor.assumeIsolated {
playPauseButton.image = player.rate > 0 ? VideoOverlayViewController.pauseImage : VideoOverlayViewController.playImage
}
})

View File

@ -15,7 +15,7 @@ public protocol GalleryContentViewController: UIViewController {
var caption: String? { get }
var contentOverlayAccessoryViewController: UIViewController? { get }
var bottomControlsAccessoryViewController: UIViewController? { get }
var canAnimateFromSourceView: Bool { get }
var presentationAnimation: GalleryContentPresentationAnimation { get }
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool)
func galleryContentDidAppear()
@ -31,8 +31,8 @@ public extension GalleryContentViewController {
nil
}
var canAnimateFromSourceView: Bool {
true
var presentationAnimation: GalleryContentPresentationAnimation {
.fromSourceView
}
func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
@ -44,3 +44,9 @@ public extension GalleryContentViewController {
func galleryContentWillDisappear() {
}
}
public enum GalleryContentPresentationAnimation {
case fade
case fromSourceView
case fromSourceViewWithoutSnapshot
}

View File

@ -30,12 +30,37 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
let itemViewController = from.currentItemViewController
if !itemViewController.content.canAnimateFromSourceView || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
if itemViewController.content.presentationAnimation == .fade || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
animateCrossFadeTransition(using: transitionContext)
return
}
let container = transitionContext.containerView
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
// is in the window's root presentation.
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
// `to.view` is already in the view hierarchy at this point; and adding it to the
// container causees it to be removed when the transition completes.
if to.view.superview == nil {
to.view.frame = container.bounds
container.addSubview(to.view)
}
let sourceSnapshot: UIView? = if itemViewController.content.presentationAnimation == .fromSourceViewWithoutSnapshot {
nil
} else {
sourceView.snapshotView(afterScreenUpdates: false)
}
if let sourceSnapshot {
let snapshotContainer = sourceView.ancestorForInsertingSnapshot
snapshotContainer.addSubview(sourceSnapshot)
let sourceFrameInShapshotContainer = snapshotContainer.convert(sourceView.bounds, from: sourceView)
sourceSnapshot.frame = sourceFrameInShapshotContainer
sourceSnapshot.layer.opacity = 1
self.sourceView.layer.opacity = 0
}
let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView)
let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view)
@ -48,38 +73,39 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
.translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY)
.scaledBy(x: scale, y: scale)
sourceView.transform = sourceToDestTransform
sourceSnapshot?.transform = sourceToDestTransform
} else {
appliedSourceToDestTransform = false
}
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
// is in the window's root presentation.
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
// `to.view` is already in the view hierarchy at this point; and adding it to the
// container causees it to be removed when the transition completes.
if to.view.superview == nil {
to.view.frame = container.bounds
container.addSubview(to.view)
}
from.view.frame = container.bounds
container.addSubview(from.view)
let contentContainer = UIView()
contentContainer.layer.masksToBounds = true
contentContainer.frame = destFrameInContainer
container.addSubview(contentContainer)
let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true
content.view.layer.masksToBounds = true
container.addSubview(content.view)
content.view.frame = destFrameInContainer
content.view.transform = .identity
content.view.layer.opacity = 1
content.view.frame = contentContainer.bounds
contentContainer.addSubview(content.view)
container.layoutIfNeeded()
// Hide overlaid controls immediately, to prevent the Live Text button's position
// getting caught up in the rest of the animation.
UIView.animate(withDuration: 0.1) {
content.setControlsVisible(false, animated: false, dueToUserInteraction: false)
}
let duration = self.transitionDuration(using: transitionContext)
var initialVelocity: CGVector
if let interactiveVelocity,
let interactiveTranslation,
// very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the springs initial undershoot
// very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the spring's initial undershoot
sqrt(pow(interactiveTranslation.x, 2) + pow(interactiveTranslation.y, 2)) > 100,
sqrt(pow(interactiveVelocity.x, 2) + pow(interactiveVelocity.y, 2)) < 2000 {
let xDistance = sourceFrameInContainer.midX - destFrameInContainer.midX
@ -102,14 +128,34 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
if appliedSourceToDestTransform {
self.sourceView.transform = origSourceTransform
sourceSnapshot?.transform = origSourceTransform
}
content.view.frame = sourceFrameInContainer
content.view.layer.opacity = 0
contentContainer.frame = sourceFrameInContainer
// Using sourceSizeWithDestAspectRatioCenteredInContentContainer does not seem to be necessary here.
// I guess autoresizing takes care of it?
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
}
// Delay fading out the content because if it's still big while it's semi-transparent,
// seeing the stuff behind it looks odd.
animator.addAnimations({
content.view.layer.opacity = 0
}, delayFactor: 0.35)
if let sourceSnapshot {
animator.addAnimations({
self.sourceView.layer.opacity = 1
sourceSnapshot.layer.opacity = 0
}, delayFactor: 0.5)
}
animator.addCompletion { _ in
sourceSnapshot?.removeFromSuperview()
// Having dismissed, we don't need to undo any of the changes to the content VC.
transitionContext.completeTransition(true)
}

View File

@ -20,6 +20,8 @@ class GalleryDismissInteraction: NSObject {
private(set) var dismissVelocity: CGPoint?
private(set) var dismissTranslation: CGPoint?
private var cancelAnimator: UIViewPropertyAnimator?
init(viewController: GalleryViewController) {
self.viewController = viewController
super.init()
@ -38,6 +40,8 @@ class GalleryDismissInteraction: NSObject {
content = viewController.currentItemViewController.takeContent()
content!.view.translatesAutoresizingMaskIntoConstraints = true
content!.view.frame = origContentFrameInGallery!
// Make sure the context remains behind the controls
content!.view.layer.zPosition = -1000
viewController.view.addSubview(content!.view)
origControlsVisible = viewController.currentItemViewController.controlsVisible
@ -53,12 +57,42 @@ class GalleryDismissInteraction: NSObject {
let translation = recognizer.translation(in: viewController.view)
let velocity = recognizer.velocity(in: viewController.view)
dismissVelocity = velocity
dismissTranslation = translation
viewController.dismiss(animated: true)
let translationMagnitude = sqrt(translation.x.magnitudeSquared + translation.y.magnitudeSquared)
let velocityMagnitude = sqrt(velocity.x.magnitudeSquared + velocity.y.magnitudeSquared)
if translationMagnitude < 150 && velocityMagnitude < 500 {
isActive = false
cancelAnimator?.stopAnimation(true)
let spring = UISpringTimingParameters(mass: 1, stiffness: 439, damping: 42, initialVelocity: .zero)
cancelAnimator = UIViewPropertyAnimator(duration: 0.2, timingParameters: spring)
cancelAnimator!.addAnimations {
self.content!.view.frame = self.origContentFrameInGallery!
self.viewController.currentItemViewController.setControlsVisible(self.origControlsVisible!, animated: false, dueToUserInteraction: false)
}
cancelAnimator!.addCompletion { _ in
guard !self.isActive else {
// bail in case the animation finishing raced with the user's interaction
return
}
self.content!.view.layer.zPosition = 0
self.content!.view.removeFromSuperview()
self.viewController.currentItemViewController.addContent()
self.content = nil
self.origContentFrameInGallery = nil
self.origControlsVisible = nil
}
cancelAnimator!.startAnimation()
// don't unset this until after dismiss is called, so that the dismiss animation controller can read it
isActive = false
} else {
dismissVelocity = velocity
dismissTranslation = translation
viewController.dismiss(animated: true)
// don't unset this until after dismiss is called, so that the dismiss animation controller can read it
isActive = false
}
default:
break

View File

@ -69,6 +69,10 @@ class GalleryItemViewController: UIViewController {
scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.delegate = self
// We calculate zoom/position ignoring the safe area, so content insets need to not incorporate it either.
// Otherwise, content that fills the screen (extending into the safe area) may still end up scrollable
// (this is readily observable with tall images on a landscape iPad).
scrollView.contentInsetAdjustmentBehavior = .never
view.addSubview(scrollView)

View File

@ -25,11 +25,31 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
let itemViewController = to.currentItemViewController
if !itemViewController.content.canAnimateFromSourceView || UIAccessibility.prefersCrossFadeTransitions {
if itemViewController.content.presentationAnimation == .fade || UIAccessibility.prefersCrossFadeTransitions {
animateCrossFadeTransition(using: transitionContext)
return
}
// Try to effectively "fade out" anything that's on top of the source view.
// The 0.1 duration makes this happen faster than the rest of the animation,
// and so less noticeable.
let sourceSnapshot: UIView? = if itemViewController.content.presentationAnimation == .fromSourceViewWithoutSnapshot {
nil
} else {
sourceView.snapshotView(afterScreenUpdates: false)
}
if let sourceSnapshot {
let snapshotContainer = sourceView.ancestorForInsertingSnapshot
snapshotContainer.addSubview(sourceSnapshot)
let sourceFrameInShapshotContainer = snapshotContainer.convert(sourceView.bounds, from: sourceView)
sourceSnapshot.frame = sourceFrameInShapshotContainer
sourceSnapshot.transform = sourceView.transform
sourceSnapshot.layer.opacity = 0
UIView.animate(withDuration: 0.1) {
sourceSnapshot.layer.opacity = 1
}
}
let container = transitionContext.containerView
to.view.frame = container.bounds
container.addSubview(to.view)
@ -56,21 +76,70 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
sourceToDestTransform = nil
}
// Grab these before taking the content out and changing the transform.
let origContentTransform = itemViewController.content.view.transform
let origContentFrame = itemViewController.content.view.frame
// The content container provides the clipping for the content view,
// which, in case the source/dest aspect ratios don't match, makes
// it look like the content is expanding out from the source rect.
let contentContainer = UIView()
contentContainer.layer.masksToBounds = true
container.insertSubview(contentContainer, belowSubview: to.view)
let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true
container.insertSubview(content.view, belowSubview: to.view)
content.view.transform = .identity
// The fade-in makes the aspect ratio handling look a little bit worse,
// but papers over the z-index change and potential corner radius change.
content.view.layer.opacity = 0
contentContainer.addSubview(content.view)
// Use a separate dimming view from to.view, so that the gallery controls can be in front of the moving content.
let dimmingView = UIView()
dimmingView.backgroundColor = .black
dimmingView.frame = container.bounds
dimmingView.layer.opacity = 0
container.insertSubview(dimmingView, belowSubview: content.view)
container.insertSubview(dimmingView, belowSubview: contentContainer)
to.view.backgroundColor = nil
to.view.layer.opacity = 0
content.view.frame = sourceFrameInContainer
content.view.layer.opacity = 0
contentContainer.frame = sourceFrameInContainer
let sourceAspectRatio: CGFloat = if sourceFrameInContainer.height > 0 {
sourceFrameInContainer.width / sourceFrameInContainer.height
} else {
0
}
let destAspectRatio: CGFloat = if destFrameInContainer.height > 0 {
destFrameInContainer.width / destFrameInContainer.height
} else {
0
}
let sourceSizeWithDestAspectRatioCenteredInContentContainer: CGRect
if 0.001 < abs(sourceAspectRatio - destAspectRatio) {
// asepct ratios are effectively equal
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(origin: .zero, size: sourceFrameInContainer.size)
} else if sourceAspectRatio < destAspectRatio {
// source aspect ratio is narrow/taller than dest
let width = sourceFrameInContainer.height * destAspectRatio
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(
x: -(width - sourceFrameInContainer.width) / 2,
y: 0,
width: width,
height: sourceFrameInContainer.height
)
} else {
// source aspect ratio is wider/shorter than dest
let height = sourceFrameInContainer.width / destAspectRatio
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(
x: 0,
y: -(height - sourceFrameInContainer.height) / 2,
width: sourceFrameInContainer.width,
height: height
)
}
content.view.frame = sourceSizeWithDestAspectRatioCenteredInContentContainer
container.layoutIfNeeded()
@ -78,8 +147,14 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
let duration = self.transitionDuration(using: transitionContext)
// rougly equivalent to duration: 0.35, bounce: 0.3
let spring = UISpringTimingParameters(mass: 1, stiffness: 322, damping: 25, initialVelocity: .zero)
// less bounce on bigger screens
let spring = if UIDevice.current.userInterfaceIdiom == .pad {
// roughly equivalent to duration: 0.35, bounce: 0.2
UISpringTimingParameters(mass: 1, stiffness: 322, damping: 28, initialVelocity: .zero)
} else {
// roughly equivalent to duration: 0.35, bounce: 0.3
UISpringTimingParameters(mass: 1, stiffness: 322, damping: 25, initialVelocity: .zero)
}
let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring)
animator.addAnimations {
@ -87,25 +162,35 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
to.view.layer.opacity = 1
content.view.frame = destFrameInContainer
contentContainer.frame = destFrameInContainer
content.view.frame = contentContainer.bounds
content.view.layer.opacity = 1
itemViewController.setControlsVisible(true, animated: false, dueToUserInteraction: false)
if let sourceToDestTransform {
sourceSnapshot?.transform = sourceToDestTransform
self.sourceView.transform = sourceToDestTransform
}
}
animator.addCompletion { _ in
sourceSnapshot?.removeFromSuperview()
self.sourceView.layer.opacity = 1
if sourceToDestTransform != nil {
self.sourceView.transform = origSourceTransform
}
contentContainer.removeFromSuperview()
dimmingView.removeFromSuperview()
to.view.backgroundColor = .black
if sourceToDestTransform != nil {
self.sourceView.transform = origSourceTransform
}
// Reset the properties we changed before re-adding the content to the scroll view.
// (I would expect UIScrollView to effectively do this itself, but w/e.)
content.view.transform = origContentTransform
content.view.frame = origContentFrame
itemViewController.addContent()
transitionContext.completeTransition(true)

View File

@ -0,0 +1,26 @@
//
// UIView+Utilities.swift
// GalleryVC
//
// Created by Shadowfacts on 11/24/24.
//
import UIKit
extension UIView {
var ancestorForInsertingSnapshot: UIView {
var view = self
while let superview = view.superview {
if superview.layer.masksToBounds {
return superview
} else if superview is UIScrollView {
return self
} else {
view = superview
}
}
return view
}
}

View File

@ -7,6 +7,7 @@ let package = Package(
name: "Pachyderm",
platforms: [
.iOS(.v16),
.macOS(.v13),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
@ -16,7 +17,6 @@ let package = Package(
],
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(url: "https://github.com/karwa/swift-url.git", exact: "0.4.2"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
@ -24,8 +24,6 @@ let package = Package(
.target(
name: "Pachyderm",
dependencies: [
.product(name: "WebURL", package: "swift-url"),
.product(name: "WebURLFoundationExtras", package: "swift-url"),
],
swiftSettings: [
.swiftLanguageMode(.v5)

View File

@ -7,7 +7,6 @@
//
import Foundation
import WebURL
/**
The base Mastodon API client.
@ -25,27 +24,30 @@ public struct Client: Sendable {
public var timeoutInterval: TimeInterval = 60
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.locale = Locale(identifier: "en_US_POSIX")
let iso8601 = ISO8601DateFormatter()
return formatter
}()
private static let iso8601Formatter = ISO8601DateFormatter()
private static func decodeDate(string: String) -> Date? {
// for the next time mastodon accidentally changes date formats >.>
return dateFormatter.date(from: string) ?? iso8601Formatter.date(from: string)
}
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom({ (decoder) in
let container = try decoder.singleValueContainer()
let str = try container.decode(String.self)
// for the next time mastodon accidentally changes date formats >.>
if let date = formatter.date(from: str) {
return date
} else if let date = iso8601.date(from: str) {
if let date = Self.decodeDate(string: str) {
return date
} else {
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
}
})
return decoder
}()
@ -105,6 +107,15 @@ public struct Client: Sendable {
return task
}
private func error(from response: HTTPURLResponse) -> ErrorType {
if response.statusCode == 429,
let date = response.value(forHTTPHeaderField: "X-RateLimit-Reset").flatMap(Self.decodeDate) {
return .rateLimited(date)
} else {
return .unexpectedStatus(response.statusCode)
}
}
@discardableResult
public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
return try await withCheckedThrowingContinuation { continuation in
@ -190,8 +201,8 @@ public struct Client: Sendable {
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
let wellKnownResults = try await run(wellKnown).0
if let url = wellKnownResults.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
let href = WebURL(url.href),
href.host == WebURL(self.baseURL)?.host {
let href = try? URL.ParseStrategy().parse(url.href),
href.host == self.baseURL.host() {
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
return try await run(nodeInfo).0
} else {
@ -575,6 +586,8 @@ extension Client {
return "Invalid Model"
case .mastodonError(let code, let error):
return "Server Error (\(code)): \(error)"
case .rateLimited(let reset):
return "Rate Limited Until \(reset.formatted(date: .omitted, time: .standard))"
}
}
}
@ -585,6 +598,7 @@ extension Client {
case invalidResponse
case invalidModel(Swift.Error)
case mastodonError(Int, String)
case rateLimited(Date)
}
enum NodeInfoError: LocalizedError {

View File

@ -6,7 +6,6 @@
//
import Foundation
import WebURL
public struct Announcement: Decodable, Sendable, Hashable, Identifiable {
public let id: String
@ -60,7 +59,7 @@ extension Announcement {
public struct Account: Decodable, Sendable, Hashable {
public let id: String
public let username: String
public let url: WebURL
@URLDecoder public var url: URL
public let acct: String
}
}
@ -68,7 +67,7 @@ extension Announcement {
extension Announcement {
public struct Status: Decodable, Sendable, Hashable {
public let id: String
public let url: WebURL
@URLDecoder public var url: URL
}
}

View File

@ -7,18 +7,17 @@
//
import Foundation
import WebURL
public struct Card: Codable, Sendable {
public let url: WebURL
@URLDecoder public var url: URL
public let title: String
public let description: String
public let image: WebURL?
@OptionalURLDecoder public var image: URL?
public let kind: Kind
public let authorName: String?
public let authorURL: WebURL?
@OptionalURLDecoder public var authorURL: URL?
public let providerName: String?
public let providerURL: WebURL?
@OptionalURLDecoder public var providerURL: URL?
public let html: String?
public let width: Int?
public let height: Int?
@ -27,15 +26,15 @@ public struct Card: Codable, Sendable {
public let history: [History]?
public init(
url: WebURL,
url: URL,
title: String,
description: String,
image: WebURL? = nil,
image: URL? = nil,
kind: Card.Kind,
authorName: String? = nil,
authorURL: WebURL? = nil,
authorURL: URL? = nil,
providerName: String? = nil,
providerURL: WebURL? = nil,
providerURL: URL? = nil,
html: String? = nil,
width: Int? = nil,
height: Int? = nil,
@ -61,15 +60,15 @@ public struct Card: Codable, Sendable {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.url = try container.decode(WebURL.self, forKey: .url)
self._url = try container.decode(URLDecoder.self, forKey: .url)
self.title = try container.decode(String.self, forKey: .title)
self.description = try container.decode(String.self, forKey: .description)
self.kind = try container.decode(Kind.self, forKey: .kind)
self.image = try? container.decodeIfPresent(WebURL.self, forKey: .image)
self._image = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .image) ?? nil
self.authorName = try? container.decodeIfPresent(String.self, forKey: .authorName)
self.authorURL = try? container.decodeIfPresent(WebURL.self, forKey: .authorURL)
self._authorURL = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .authorURL) ?? nil
self.providerName = try? container.decodeIfPresent(String.self, forKey: .providerName)
self.providerURL = try? container.decodeIfPresent(WebURL.self, forKey: .providerURL)
self._providerURL = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .providerURL) ?? nil
self.html = try? container.decodeIfPresent(String.self, forKey: .html)
self.width = try? container.decodeIfPresent(Int.self, forKey: .width)
self.height = try? container.decodeIfPresent(Int.self, forKey: .height)

View File

@ -7,14 +7,11 @@
//
import Foundation
import WebURL
public struct Emoji: Codable, Sendable {
public let shortcode: String
// these shouldn't need to be WebURLs as they're not external resources,
// but some instances (pleroma?) has emoji urls that Foundation considers malformed so we use WebURL to be more lenient
public let url: WebURL
public let staticURL: WebURL
@URLDecoder public var url: URL
@URLDecoder public var staticURL: URL
public let visibleInPicker: Bool
public let category: String?
@ -22,8 +19,8 @@ public struct Emoji: Codable, Sendable {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.shortcode = try container.decode(String.self, forKey: .shortcode)
self.url = try container.decode(WebURL.self, forKey: .url)
self.staticURL = try container.decode(WebURL.self, forKey: .staticURL)
self._url = try container.decode(URLDecoder.self, forKey: .url)
self._staticURL = try container.decode(URLDecoder.self, forKey: .staticURL)
self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker)
self.category = try container.decodeIfPresent(String.self, forKey: .category)
}

View File

@ -7,12 +7,10 @@
//
import Foundation
import WebURL
import WebURLFoundationExtras
public struct Hashtag: Codable, Sendable {
public let name: String
public let url: WebURL
@URLDecoder public var url: URL
/// Only present when returned from the trending hashtags endpoint
public let history: [History]?
/// Only present on Mastodon >= 4 and when logged in
@ -20,7 +18,7 @@ public struct Hashtag: Codable, Sendable {
public init(name: String, url: URL) {
self.name = name
self.url = WebURL(url)!
self.url = url
self.history = nil
self.following = nil
}
@ -29,7 +27,7 @@ public struct Hashtag: Codable, Sendable {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name)
// pixelfed (possibly others) don't fully escape special characters in the hashtag url
self.url = try container.decode(WebURL.self, forKey: .url)
self._url = try container.decode(URLDecoder.self, forKey: .url)
self.history = try container.decodeIfPresent([History].self, forKey: .history)
self.following = try container.decodeIfPresent(Bool.self, forKey: .following)
}

View File

@ -7,10 +7,9 @@
//
import Foundation
import WebURL
public struct Mention: Codable, Sendable {
public let url: WebURL
@URLDecoder public var url: URL
public let username: String
public let acct: String
/// The instance-local ID of the user being mentioned.
@ -21,15 +20,10 @@ public struct Mention: Codable, Sendable {
self.username = try container.decode(String.self, forKey: .username)
self.acct = try container.decode(String.self, forKey: .acct)
self.id = try container.decode(String.self, forKey: .id)
do {
self.url = try container.decode(WebURL.self, forKey: .url)
} catch {
let s = try? container.decode(String.self, forKey: .url)
throw DecodingError.dataCorruptedError(forKey: .url, in: container, debugDescription: "Could not decode URL '\(s ?? "<failed to decode string>")'")
}
self._url = try container.decode(URLDecoder.self, forKey: .url)
}
public init(url: WebURL, username: String, acct: String, id: String) {
public init(url: URL, username: String, acct: String, id: String) {
self.url = url
self.username = username
self.acct = acct

View File

@ -7,7 +7,6 @@
//
import Foundation
import WebURL
public struct Notification: Decodable, Sendable {
public let id: String
@ -18,7 +17,7 @@ public struct Notification: Decodable, Sendable {
// Only present for pleroma emoji reactions
// Either an emoji or :shortcode: (for akkoma custom emoji reactions)
public let emoji: String?
public let emojiURL: WebURL?
@OptionalURLDecoder public var emojiURL: URL?
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
@ -33,7 +32,7 @@ public struct Notification: Decodable, Sendable {
self.account = try container.decode(Account.self, forKey: .account)
self.status = try container.decodeIfPresent(Status.self, forKey: .status)
self.emoji = try container.decodeIfPresent(String.self, forKey: .emoji)
self.emojiURL = try container.decodeIfPresent(WebURL.self, forKey: .emojiURL)
self._emojiURL = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .emojiURL) ?? nil
}
public static func dismiss(id notificationID: String) -> Request<Empty> {

View File

@ -6,14 +6,13 @@
//
import Foundation
import WebURL
public struct PushNotification: Decodable {
public let accessToken: String
public let preferredLocale: String
public let notificationID: String
public let notificationType: Notification.Kind
public let icon: WebURL
@URLDecoder public var icon: URL
public let title: String
public let body: String
@ -29,7 +28,7 @@ public struct PushNotification: Decodable {
self.notificationID = i.description
}
self.notificationType = try container.decode(Notification.Kind.self, forKey: .notificationType)
self.icon = try container.decode(WebURL.self, forKey: .icon)
self._icon = try container.decode(URLDecoder.self, forKey: .icon)
self.title = try container.decode(String.self, forKey: .title)
self.body = try container.decode(String.self, forKey: .body)
}

View File

@ -7,7 +7,6 @@
//
import Foundation
import WebURL
public final class Status: StatusProtocol, Decodable, Sendable {
/// The pseudo-visibility used by instance types (Akkoma) that overload the visibility for local-only posts.
@ -15,7 +14,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
public let id: String
public let uri: String
public let url: WebURL?
private let _url: OptionalURLDecoder
public var url: URL? { _url.wrappedValue }
public let account: Account
public let inReplyToID: String?
public let inReplyToAccountID: String?
@ -55,13 +55,13 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.id = try container.decode(String.self, forKey: .id)
self.uri = try container.decode(String.self, forKey: .uri)
do {
self.url = try container.decodeIfPresent(WebURL.self, forKey: .url)
self._url = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .url) ?? nil
} catch {
let s = try? container.decode(String.self, forKey: .url)
if s == "" {
self.url = nil
self._url = OptionalURLDecoder(wrappedValue: nil)
} else {
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath + [CodingKeys.url], debugDescription: "Could not decode URL '\(s ?? "<failed to decode string>")'", underlyingError: error))
throw error
}
}
self.account = try container.decode(Account.self, forKey: .account)

View File

@ -7,7 +7,6 @@
//
import Foundation
import WebURL
public struct NotificationGroup: Identifiable, Hashable, Sendable {
public private(set) var notifications: [Notification]
@ -150,7 +149,7 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
case poll
case update
case status
case emojiReaction(String, WebURL?)
case emojiReaction(String, URL?)
case unknown
var notificationKind: Notification.Kind {

View File

@ -0,0 +1,86 @@
//
// URLDecoder.swift
// Pachyderm
//
// Created by Shadowfacts on 12/15/24.
//
import Foundation
private let parseStrategy = URL.ParseStrategy()
.scheme(.required)
.user(.optional)
.password(.optional)
.host(.required)
.port(.optional)
.path(.optional)
.query(.optional)
.fragment(.optional)
private let formatStyle = URL.FormatStyle()
.scheme(.always)
.user(.omitWhen(.user, matches: [""]))
.password(.omitWhen(.password, matches: [""]))
.host(.always)
.port(.omitIfHTTPFamily)
.path(.always)
.query(.omitWhen(.query, matches: [""]))
.fragment(.omitWhen(.fragment, matches: [""]))
@propertyWrapper
public struct URLDecoder: Codable, Sendable, Hashable {
public var wrappedValue: URL
public init(wrappedValue: URL) {
self.wrappedValue = wrappedValue
}
public init(from decoder: any Decoder) throws {
let s = try decoder.singleValueContainer().decode(String.self)
self.wrappedValue = try parseStrategy.parse(s)
}
public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(wrappedValue.formatted(formatStyle))
}
}
@propertyWrapper
public struct OptionalURLDecoder: Codable, Sendable, Hashable, ExpressibleByNilLiteral {
public var wrappedValue: URL?
public init(wrappedValue: URL?) {
self.wrappedValue = wrappedValue
}
public init(nilLiteral: ()) {
self.wrappedValue = nil
}
public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self.wrappedValue = nil
} else {
let s = try container.decode(String.self)
if s.isEmpty {
self.wrappedValue = nil
} else {
do {
self.wrappedValue = try parseStrategy.parse(s)
} catch {
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "Could not decode URL '\(s)'", underlyingError: error))
}
}
}
}
public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
if let wrappedValue {
try container.encode(wrappedValue.formatted(formatStyle))
} else {
try container.encodeNil()
}
}
}

View File

@ -197,72 +197,72 @@ class NotificationGroupTests: XCTestCase {
func testGroupSimple() {
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeA2], only: [.favourite])
XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2])!])
XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!])
}
func testGroupWithOtherGroupableInBetween() {
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeB, likeA2], only: [.favourite])
XCTAssertEqual(groups, [
NotificationGroup(notifications: [likeA1, likeA2])!,
NotificationGroup(notifications: [likeB])!,
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
NotificationGroup(notifications: [likeB], kind: .favourite)!,
])
}
func testDontGroupWithUngroupableInBetween() {
let groups = NotificationGroup.createGroups(notifications: [likeA1, mentionB, likeA2], only: [.favourite])
XCTAssertEqual(groups, [
NotificationGroup(notifications: [likeA1])!,
NotificationGroup(notifications: [mentionB])!,
NotificationGroup(notifications: [likeA2])!,
NotificationGroup(notifications: [likeA1], kind: .favourite)!,
NotificationGroup(notifications: [mentionB], kind: .mention)!,
NotificationGroup(notifications: [likeA2], kind: .favourite)!,
])
}
func testMergeSimpleGroups() {
let group1 = NotificationGroup(notifications: [likeA1])!
let group2 = NotificationGroup(notifications: [likeA2])!
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
let group2 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
let merged = NotificationGroup.mergeGroups(first: [group1], second: [group2], only: [.favourite])
XCTAssertEqual(merged, [
NotificationGroup(notifications: [likeA1, likeA2])!
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!
])
}
func testMergeGroupsWithOtherGroupableInBetween() {
let group1 = NotificationGroup(notifications: [likeA1])!
let group2 = NotificationGroup(notifications: [likeB])!
let group3 = NotificationGroup(notifications: [likeA2])!
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
let group2 = NotificationGroup(notifications: [likeB], kind: .favourite)!
let group3 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
XCTAssertEqual(merged, [
NotificationGroup(notifications: [likeA1, likeA2])!,
NotificationGroup(notifications: [likeB])!,
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
NotificationGroup(notifications: [likeB], kind: .favourite)!,
])
let merged2 = NotificationGroup.mergeGroups(first: [group1], second: [group2, group3], only: [.favourite])
XCTAssertEqual(merged2, [
NotificationGroup(notifications: [likeA1, likeA2])!,
NotificationGroup(notifications: [likeB])!,
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
NotificationGroup(notifications: [likeB], kind: .favourite)!,
])
let group4 = NotificationGroup(notifications: [likeB2])!
let group5 = NotificationGroup(notifications: [mentionB])!
let group4 = NotificationGroup(notifications: [likeB2], kind: .favourite)!
let group5 = NotificationGroup(notifications: [mentionB], kind: .mention)!
let merged3 = NotificationGroup.mergeGroups(first: [group1, group5, group2], second: [group4, group3], only: [.favourite])
print(merged3.count)
XCTAssertEqual(merged3, [
group1,
group5,
NotificationGroup(notifications: [likeB, likeB2]),
NotificationGroup(notifications: [likeB, likeB2], kind: .favourite),
group3
])
}
func testDontMergeWithUngroupableInBetween() {
let group1 = NotificationGroup(notifications: [likeA1])!
let group2 = NotificationGroup(notifications: [mentionB])!
let group3 = NotificationGroup(notifications: [likeA2])!
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
let group2 = NotificationGroup(notifications: [mentionB], kind: .mention)!
let group3 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
XCTAssertEqual(merged, [
NotificationGroup(notifications: [likeA1])!,
NotificationGroup(notifications: [mentionB])!,
NotificationGroup(notifications: [likeA2])!,
NotificationGroup(notifications: [likeA1], kind: .favourite)!,
NotificationGroup(notifications: [mentionB], kind: .mention)!,
NotificationGroup(notifications: [likeA2], kind: .favourite)!,
])
}

View File

@ -6,20 +6,21 @@
//
import XCTest
import WebURL
import WebURLFoundationExtras
@testable import Pachyderm
class URLTests: XCTestCase {
func testDecodeURL() {
XCTAssertNotNil(WebURL(URL(string: "https://xn--baw-joa.social/@unituebingen")!))
XCTAssertNotNil(WebURL("https://xn--baw-joa.social/@unituebingen"))
XCTAssertNotNil(URLComponents(string: "https://xn--baw-joa.social/test/é"))
XCTAssertNotNil(WebURL("https://xn--baw-joa.social/test/é"))
if #available(iOS 16.0, *) {
XCTAssertNotNil(try? URL.ParseStrategy().parse("https://xn--baw-joa.social/test/é"))
XCTAssertNotNil(try? URL.ParseStrategy().parse("http://見.香港/热狗/🌭"))
}
XCTAssertNotNil(try? URL.ParseStrategy().parse("https://xn--baw-joa.social/test/é"))
XCTAssertNotNil(try? URL.ParseStrategy().parse("http://見.香港/热狗/🌭"))
}
func testRoundtripURL() throws {
let orig = URLDecoder(wrappedValue: URL(string: "https://example.com")!)
let encoded = try JSONEncoder().encode(orig)
print(String(data: encoded, encoding: .utf8)!)
let decoded = try JSONDecoder().decode(URLDecoder.self, from: encoded)
XCTAssertEqual(orig.wrappedValue, decoded.wrappedValue)
}
}

View File

@ -9,7 +9,6 @@
import SwiftUI
import ComposeUI
import TuskerComponents
import WebURLFoundationExtras
import Combine
import TuskerPreferences
@ -46,7 +45,7 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
currentAccountContainerView: { AnyView(SwitchAccountContainerView(content: $0, mastodonContextPublisher: mastodonContextPublisher)) },
replyContentView: { _, _ in fatalError("replies aren't allowed in share sheet") },
emojiImageView: {
AnyView(AsyncImage(url: URL($0.url)!) {
AnyView(AsyncImage(url: $0.url) {
$0
.resizable()
.aspectRatio(contentMode: .fit)

View File

@ -103,7 +103,6 @@
D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E02BC61C6700208903 /* UserAccounts */; };
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E42BC6313400208903 /* Pachyderm */; };
D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4222BC7842C00208903 /* HTMLStreamer */; };
D630C4252BC7845800208903 /* WebURL in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4242BC7845800208903 /* WebURL */; };
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; };
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; };
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; };
@ -164,7 +163,6 @@
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; };
D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; };
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */; };
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = D6676CA427A8D0020052936B /* WebURLFoundationExtras */; };
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; };
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; };
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; };
@ -203,20 +201,16 @@
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */; };
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */; };
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */; };
D69261232BB3AEFB0023152C /* VideoOverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69261222BB3AEFB0023152C /* VideoOverlayViewController.swift */; };
D69261272BB3BA610023152C /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69261262BB3BA610023152C /* Box.swift */; };
D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */ = {isa = PBXBuildFile; productRef = D6934F2B2BA7AD32002B1C8D /* GalleryVC */; };
D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */; };
D6934F302BA7AF91002B1C8D /* ImageGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */; };
D6934F322BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */; };
D6934F302BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F2F2BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift */; };
D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */; };
D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */; };
D6934F382BA8E2B7002B1C8D /* GifvController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F372BA8E2B7002B1C8D /* GifvController.swift */; };
D6934F3A2BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F392BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift */; };
D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */; };
D6934F3C2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3B2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift */; };
D6934F3E2BAA19D5002B1C8D /* ImageActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3D2BAA19D5002B1C8D /* ImageActivityItemSource.swift */; };
D6934F402BAA19EC002B1C8D /* VideoActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F3F2BAA19EC002B1C8D /* VideoActivityItemSource.swift */; };
D6934F422BAC7D6E002B1C8D /* VideoControlsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6934F412BAC7D6E002B1C8D /* VideoControlsViewController.swift */; };
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; };
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; };
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5823FE24300061E07D /* InteractivePushTransition.swift */; };
@ -637,19 +631,15 @@
D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNoContentCollectionViewCell.swift; sourceTree = "<group>"; };
D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderMovedOverlayView.swift; sourceTree = "<group>"; };
D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityHandlingContext.swift; sourceTree = "<group>"; };
D69261222BB3AEFB0023152C /* VideoOverlayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoOverlayViewController.swift; sourceTree = "<group>"; };
D69261262BB3BA610023152C /* Box.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Box.swift; sourceTree = "<group>"; };
D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentsGalleryDataSource.swift; sourceTree = "<group>"; };
D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryContentViewController.swift; sourceTree = "<group>"; };
D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingGalleryContentViewController.swift; sourceTree = "<group>"; };
D6934F2F2BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrayscalableImageGalleryContentViewController.swift; sourceTree = "<group>"; };
D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGalleryDataSource.swift; sourceTree = "<group>"; };
D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvGalleryContentViewController.swift; sourceTree = "<group>"; };
D6934F372BA8E2B7002B1C8D /* GifvController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvController.swift; sourceTree = "<group>"; };
D6934F392BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FallbackGalleryContentViewController.swift; sourceTree = "<group>"; };
D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoGalleryContentViewController.swift; sourceTree = "<group>"; };
D6934F3B2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrayscalableVideoGalleryContentViewController.swift; sourceTree = "<group>"; };
D6934F3D2BAA19D5002B1C8D /* ImageActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageActivityItemSource.swift; sourceTree = "<group>"; };
D6934F3F2BAA19EC002B1C8D /* VideoActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoActivityItemSource.swift; sourceTree = "<group>"; };
D6934F412BAC7D6E002B1C8D /* VideoControlsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoControlsViewController.swift; sourceTree = "<group>"; };
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = "<group>"; };
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedNavigationViewController.swift; sourceTree = "<group>"; };
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractivePushTransition.swift; sourceTree = "<group>"; };
@ -826,7 +816,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D630C4252BC7845800208903 /* WebURL in Frameworks */,
D62220472C7EA8DF003E43B7 /* TuskerPreferences in Frameworks */,
D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */,
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */,
@ -863,7 +852,6 @@
D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */,
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
D60BB3942B30076F00DAEA65 /* HTMLStreamer in Frameworks */,
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */,
);
@ -899,13 +887,9 @@
children = (
D6934F2D2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift */,
D6934F332BA8D65A002B1C8D /* ImageGalleryDataSource.swift */,
D6934F312BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift */,
D6934F2F2BA7AF91002B1C8D /* ImageGalleryContentViewController.swift */,
D6934F2F2BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift */,
D6934F352BA8E020002B1C8D /* GifvGalleryContentViewController.swift */,
D6934F3B2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift */,
D69261222BB3AEFB0023152C /* VideoOverlayViewController.swift */,
D6934F412BAC7D6E002B1C8D /* VideoControlsViewController.swift */,
D6934F392BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift */,
D6934F3B2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift */,
);
path = Gallery;
sourceTree = "<group>";
@ -1793,7 +1777,6 @@
D630C3E02BC61C6700208903 /* UserAccounts */,
D630C3E42BC6313400208903 /* Pachyderm */,
D630C4222BC7842C00208903 /* HTMLStreamer */,
D630C4242BC7845800208903 /* WebURL */,
D62220462C7EA8DF003E43B7 /* TuskerPreferences */,
);
productName = NotificationExtension;
@ -1845,7 +1828,6 @@
);
name = Tusker;
packageProductDependencies = (
D6676CA427A8D0020052936B /* WebURLFoundationExtras */,
D674A50827F9128D00BA03AC /* Pachyderm */,
D6552366289870790048A653 /* ScreenCorners */,
D63CC701290EC0B8000E19DE /* Sentry */,
@ -1972,7 +1954,6 @@
);
mainGroup = D6D4DDC3212518A000E1C4BB;
packageReferences = (
D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */,
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */,
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */,
@ -2122,7 +2103,6 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D6934F422BAC7D6E002B1C8D /* VideoControlsViewController.swift in Sources */,
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */,
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */,
@ -2169,8 +2149,7 @@
D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */,
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */,
D69261232BB3AEFB0023152C /* VideoOverlayViewController.swift in Sources */,
D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */,
D6934F3C2BAA0F80002B1C8D /* GrayscalableVideoGalleryContentViewController.swift in Sources */,
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */,
@ -2190,7 +2169,6 @@
D6D94955298963A900C59229 /* Colors.swift in Sources */,
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */,
D6934F322BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift in Sources */,
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */,
D64A50482C739DEA009D7193 /* BaseMainTabBarViewController.swift in Sources */,
@ -2218,7 +2196,6 @@
D630C3CC2BC5FD4600208903 /* GetAuthorizationTokenService.swift in Sources */,
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */,
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
D6934F3A2BA8F3D7002B1C8D /* FallbackGalleryContentViewController.swift in Sources */,
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */,
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
@ -2341,7 +2318,7 @@
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
D64A50BE2C752247009D7193 /* AdaptableNavigationController.swift in Sources */,
D6934F302BA7AF91002B1C8D /* ImageGalleryContentViewController.swift in Sources */,
D6934F302BA7AF91002B1C8D /* GrayscalableImageGalleryContentViewController.swift in Sources */,
D6934F3E2BAA19D5002B1C8D /* ImageActivityItemSource.swift in Sources */,
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */,
@ -3290,14 +3267,6 @@
minimumVersion = 1.0.1;
};
};
D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/karwa/swift-url";
requirement = {
kind = exactVersion;
version = 0.4.2;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@ -3335,11 +3304,6 @@
package = D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */;
productName = HTMLStreamer;
};
D630C4242BC7845800208903 /* WebURL */ = {
isa = XCSwiftPackageProductDependency;
package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */;
productName = WebURL;
};
D635237029B78A7D009ED5E7 /* TuskerComponents */ = {
isa = XCSwiftPackageProductDependency;
productName = TuskerComponents;
@ -3358,11 +3322,6 @@
isa = XCSwiftPackageProductDependency;
productName = TTTKit;
};
D6676CA427A8D0020052936B /* WebURLFoundationExtras */ = {
isa = XCSwiftPackageProductDependency;
package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */;
productName = WebURLFoundationExtras;
};
D674A50827F9128D00BA03AC /* Pachyderm */ = {
isa = XCSwiftPackageProductDependency;
productName = Pachyderm;

View File

@ -9,7 +9,6 @@
import Foundation
import CoreData
import Pachyderm
import WebURLFoundationExtras
@objc(FollowedHashtag)
public final class FollowedHashtag: NSManagedObject {
@ -33,6 +32,6 @@ extension FollowedHashtag {
convenience init(hashtag: Hashtag, context: NSManagedObjectContext) {
self.init(context: context)
self.name = hashtag.name
self.url = URL(hashtag.url)!
self.url = hashtag.url
}
}

View File

@ -375,13 +375,14 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer, @unchecked Se
}
}
func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
backgroundContext.perform {
func addAll(notifications: [Pachyderm.Notification], in context: NSManagedObjectContext? = nil, completion: (() -> Void)? = nil) {
let context = context ?? backgroundContext
context.perform {
let statuses = notifications.compactMap { $0.status }
let accounts = notifications.map { $0.account }
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
self.save(context: self.backgroundContext)
statuses.forEach { self.upsert(status: $0, context: context) }
accounts.forEach { self.upsert(account: $0, in: context) }
self.save(context: context)
completion?()
statuses.forEach { self.statusSubject.send($0.id) }
accounts.forEach { self.accountSubject.send($0.id) }

View File

@ -9,7 +9,6 @@
import Foundation
import CoreData
import Pachyderm
import WebURLFoundationExtras
import UserAccounts
@objc(SavedHashtag)
@ -42,6 +41,6 @@ extension SavedHashtag {
self.init(context: context)
self.accountID = account.id
self.name = hashtag.name
self.url = URL(hashtag.url)!
self.url = hashtag.url
}
}

View File

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

View File

@ -8,8 +8,7 @@
import UIKit
import HTMLStreamer
import WebURL
import WebURLFoundationExtras
import Pachyderm
class HTMLConverter {
@ -45,17 +44,7 @@ extension HTMLConverter {
// note: this is duplicated in NotificationExtension
struct Callbacks: HTMLConversionCallbacks {
static func makeURL(string: String) -> URL? {
// Converting WebURL to URL is a small but non-trivial expense (since it works by
// serializing the WebURL as a string and then having Foundation parse it again),
// so, if available, use the system parser which doesn't require another round trip.
if let url = try? URL.ParseStrategy().parse(string) {
url
} else if let web = WebURL(string),
let url = URL(web) {
url
} else {
nil
}
try? URL.ParseStrategy().parse(string)
}
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {

View File

@ -8,7 +8,6 @@
import UIKit
import Pachyderm
import WebURL
class AnnouncementContentTextView: ContentTextView {
@ -30,7 +29,7 @@ class AnnouncementContentTextView: ContentTextView {
override func getMention(for url: URL, text: String) -> Mention? {
announcement?.mentions.first {
URL($0.url) == url
$0.url == url
}.map {
Mention(url: $0.url, username: $0.username, acct: $0.acct, id: $0.id)
}
@ -38,7 +37,7 @@ class AnnouncementContentTextView: ContentTextView {
override func getHashtag(for url: URL, text: String) -> Hashtag? {
announcement?.tags.first {
URL($0.url) == url
$0.url == url
}
}

View File

@ -9,7 +9,6 @@
import SwiftUI
import Pachyderm
import TuskerComponents
import WebURLFoundationExtras
struct AnnouncementListRow: View {
@Binding var announcement: Announcement
@ -116,8 +115,8 @@ struct AnnouncementListRow: View {
let url: URL?
let staticURL: URL?
if case .custom(let emoji) = reaction {
url = URL(emoji.url)
staticURL = URL(emoji.staticURL)
url = emoji.url
staticURL = emoji.staticURL
} else {
url = nil
staticURL = nil

View File

@ -8,8 +8,6 @@
import UIKit
import Pachyderm
import WebURL
import WebURLFoundationExtras
private let mastodonRemoteStatusRegex = try! NSRegularExpression(pattern: "^/@.+@.+/\\d{18}")
private func isLikelyMastodonRemoteStatus(url: URL) -> Bool {
@ -229,10 +227,10 @@ class ConversationViewController: UIViewController {
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
effectiveURL = location
} else {
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
effectiveURL = url.formatted(.url.fragment(.never))
}
} else {
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
effectiveURL = url.formatted(.url.fragment(.never))
}
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
@ -484,3 +482,11 @@ extension ConversationViewController: StatusBarTappableViewController {
}
}
}
extension ConversationViewController: RefreshableViewController {
func refresh() {
Task {
await refreshContext()
}
}
}

View File

@ -10,7 +10,6 @@ import UIKit
import Combine
import Pachyderm
import CoreData
import WebURLFoundationExtras
class ExploreViewController: UIViewController, UICollectionViewDelegate, CollectionViewController {
@ -560,10 +559,7 @@ extension ExploreViewController: UICollectionViewDragDelegate {
activity.displaysAuxiliaryScene = true
provider = NSItemProvider(object: activity)
case let .savedHashtag(hashtag):
guard let url = URL(hashtag.url) else {
return []
}
provider = NSItemProvider(object: url as NSURL)
provider = NSItemProvider(object: hashtag.url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: accountID) {
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)

View File

@ -8,7 +8,6 @@
import UIKit
import Pachyderm
import WebURLFoundationExtras
import Combine
class TrendingHashtagsViewController: UIViewController, CollectionViewController {
@ -277,11 +276,10 @@ extension TrendingHashtagsViewController: UICollectionViewDelegate {
extension TrendingHashtagsViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let item = dataSource.itemIdentifier(for: indexPath),
case let .tag(hashtag) = item,
let url = URL(hashtag.url) else {
case let .tag(hashtag) = item else {
return []
}
let provider = NSItemProvider(object: url as NSURL)
let provider = NSItemProvider(object: hashtag.url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) {
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)

View File

@ -9,7 +9,6 @@
#if !os(visionOS)
import UIKit
import Pachyderm
import WebURLFoundationExtras
import HTMLStreamer
class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
@ -71,7 +70,7 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
self.card = card
self.thumbnailView.image = nil
thumbnailView.update(for: card.image.flatMap { URL($0) }, blurhash: card.blurhash)
thumbnailView.update(for: card.image, blurhash: card.blurhash)
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
titleLabel.text = title

View File

@ -9,22 +9,17 @@
#if os(visionOS)
import SwiftUI
import Pachyderm
import WebURLFoundationExtras
import HTMLStreamer
struct TrendingLinkCardView: View {
let card: Card
private var imageURL: URL? {
if let image = card.image {
URL(image)
} else {
nil
}
card.image
}
private var descriptionText: String {
var converter = TextConverter(configuration: .init(insertNewlines: false))
let converter = TextConverter(configuration: .init(insertNewlines: false))
return converter.convert(html: card.description)
}

View File

@ -8,7 +8,6 @@
import UIKit
import Pachyderm
import WebURLFoundationExtras
import SafariServices
import Combine
#if os(visionOS)
@ -293,21 +292,19 @@ extension TrendingLinksViewController: UICollectionViewDelegate {
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard case .link(let card) = dataSource.itemIdentifier(for: indexPath),
let url = URL(card.url) else {
guard case .link(let card) = dataSource.itemIdentifier(for: indexPath) else {
return
}
selected(url: url)
selected(url: card.url)
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard case .link(let card) = dataSource.itemIdentifier(for: indexPath),
let url = URL(card.url),
let cell = collectionView.cellForItem(at: indexPath) else {
return nil
}
return UIContextMenuConfiguration {
let vc = SFSafariViewController(url: url)
let vc = SFSafariViewController(url: card.url)
#if !os(visionOS)
vc.preferredControlTintColor = Preferences.shared.accentColor.color
#endif
@ -324,11 +321,10 @@ extension TrendingLinksViewController: UICollectionViewDelegate {
extension TrendingLinksViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard case .link(let card) = dataSource.itemIdentifier(for: indexPath),
let url = URL(card.url) else {
guard case .link(let card) = dataSource.itemIdentifier(for: indexPath) else {
return []
}
return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))]
return [UIDragItem(itemProvider: NSItemProvider(object: card.url as NSURL))]
}
}

View File

@ -513,9 +513,7 @@ extension TrendsViewController: UICollectionViewDelegate {
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
case let .link(card):
if let url = URL(card.url) {
selected(url: url)
}
selected(url: card.url)
case let .status(id, state):
selected(status: id, state: state.copy())
@ -544,12 +542,9 @@ extension TrendsViewController: UICollectionViewDelegate {
}
case let .link(card):
guard let url = URL(card.url) else {
return nil
}
let cell = collectionView.cellForItem(at: indexPath)!
return UIContextMenuConfiguration {
let vc = SFSafariViewController(url: url)
let vc = SFSafariViewController(url: card.url)
#if !os(visionOS)
vc.preferredControlTintColor = Preferences.shared.accentColor.color
#endif
@ -624,10 +619,7 @@ extension TrendsViewController: UICollectionViewDragDelegate {
return []
case let .tag(hashtag):
guard let url = URL(hashtag.url) else {
return []
}
let provider = NSItemProvider(object: url as NSURL)
let provider = NSItemProvider(object: hashtag.url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) {
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
@ -635,10 +627,7 @@ extension TrendsViewController: UICollectionViewDragDelegate {
return [UIDragItem(itemProvider: provider)]
case let .link(card):
guard let url = URL(card.url) else {
return []
}
return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))]
return [UIDragItem(itemProvider: NSItemProvider(object: card.url as NSURL))]
case let .status(id, _):
guard let status = mastodonController.persistentContainer.status(for: id),

View File

@ -8,7 +8,6 @@
import UIKit
import UserAccounts
import WebURL
class FastSwitchingAccountView: UIView {
@ -131,11 +130,7 @@ class FastSwitchingAccountView: UIView {
private func setupAccount(account: UserAccountInfo) {
usernameLabel.text = account.username
if let domain = WebURL.Domain(account.instanceURL.host!) {
instanceLabel.text = domain.render(.uncheckedUnicodeString)
} else {
instanceLabel.text = account.instanceURL.host!
}
instanceLabel.text = account.instanceURL.host(percentEncoded: false)
let controller = MastodonController.getForAccount(account)
avatarTask = Task {
guard let account = try? await controller.getOwnAccount(),

View File

@ -69,4 +69,8 @@ class GifvGalleryContentViewController: UIViewController, GalleryContentViewCont
[VideoActivityItemSource(asset: controller.item.asset, url: url)]
}
var presentationAnimation: GalleryContentPresentationAnimation {
.fromSourceViewWithoutSnapshot
}
}

View File

@ -0,0 +1,60 @@
//
// GrayscalableImageGalleryContentViewController.swift
// Tusker
//
// Created by Shadowfacts on 11/21/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import TuskerComponents
import GalleryVC
class GrayscalableImageGalleryContentViewController: GalleryVC.ImageGalleryContentViewController {
private let url: URL
private let originalImage: UIImage
private let originalData: Data?
private var isGrayscale = false
init(url: URL, caption: String?, originalData: Data?, image: UIImage, gifController: GIFController?) {
self.url = url
self.originalImage = image
self.originalData = originalData
super.init(image: image, caption: caption, gifController: gifController)
isGrayscale = Preferences.shared.grayscaleImages
if isGrayscale {
self.image = ImageGrayscalifier.convert(url: url, image: image) ?? image
}
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
@MainActor required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func preferencesChanged() {
if isGrayscale != Preferences.shared.grayscaleImages {
isGrayscale = Preferences.shared.grayscaleImages
let image = if isGrayscale {
ImageGrayscalifier.convert(url: url, image: originalImage)
} else {
originalImage
}
if let image {
self.image = image
}
}
}
override var activityItemsForSharing: [Any] {
if let data = originalData ?? image.pngData() {
return [ImageActivityItemSource(data: data, url: url, image: image)]
} else {
return []
}
}
}

View File

@ -0,0 +1,83 @@
//
// GrayscalableVideoGalleryContentViewController.swift
// Tusker
//
// Created by Shadowfacts on 11/21/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import GalleryVC
import AVFoundation
class GrayscalableVideoGalleryContentViewController: GalleryVC.VideoGalleryContentViewController {
private var audioSessionToken: AudioSessionCoordinator.Token?
private var isGrayscale: Bool
private var isFirstAppearance = true
override init(url: URL, caption: String?) {
self.isGrayscale = Preferences.shared.grayscaleImages
super.init(url: url, caption: caption)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
@MainActor required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override class func createItem(asset: AVAsset) -> AVPlayerItem {
let item = AVPlayerItem(asset: asset)
if Preferences.shared.grayscaleImages {
#if os(visionOS)
#warning("Use async AVVideoComposition CIFilter initializer")
#else
let filter = CIFilter(name: "CIColorMonochrome")!
filter.setValue(CIColor(red: 0.85, green: 0.85, blue: 0.85), forKey: "inputColor")
filter.setValue(1.0, forKey: "inputIntensity")
item.videoComposition = AVVideoComposition(asset: asset, applyingCIFiltersWithHandler: { request in
filter.setValue(request.sourceImage, forKey: "inputImage")
request.finish(with: filter.outputImage!, context: nil)
})
#endif
}
return item
}
@objc private func preferencesChanged() {
if isGrayscale != Preferences.shared.grayscaleImages {
let isPlaying = player.rate > 0
isGrayscale = Preferences.shared.grayscaleImages
replaceCurrentItem(with: Self.createItem(asset: item.asset))
if isPlaying {
player.play()
}
}
}
override func galleryContentDidAppear() {
super.galleryContentDidAppear()
let wasFirstAppearance = isFirstAppearance
isFirstAppearance = false
audioSessionToken = AudioSessionCoordinator.shared.beginPlayback(mode: .video) {
if wasFirstAppearance {
DispatchQueue.main.async {
self.player.play()
}
}
}
}
override func galleryContentWillDisappear() {
super.galleryContentWillDisappear()
if let audioSessionToken {
AudioSessionCoordinator.shared.endPlayback(token: audioSessionToken)
}
}
}

View File

@ -34,7 +34,7 @@ class ImageGalleryDataSource: GalleryDataSource {
} else {
nil
}
return ImageGalleryContentViewController(
return GrayscalableImageGalleryContentViewController(
url: url,
caption: nil,
originalData: entry.data,
@ -52,7 +52,7 @@ class ImageGalleryDataSource: GalleryDataSource {
} else {
nil
}
return ImageGalleryContentViewController(
return GrayscalableImageGalleryContentViewController(
url: self.url,
caption: nil,
originalData: data,

View File

@ -33,7 +33,7 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
case .image:
if let view = attachmentView(for: attachment),
let image = view.attachmentImage {
return ImageGalleryContentViewController(
return GrayscalableImageGalleryContentViewController(
url: attachment.url,
caption: attachment.description,
originalData: view.originalData,
@ -49,7 +49,7 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
} else {
nil
}
return ImageGalleryContentViewController(
return GrayscalableImageGalleryContentViewController(
url: attachment.url,
caption: attachment.description,
originalData: entry.data,
@ -68,7 +68,7 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
} else {
nil
}
return ImageGalleryContentViewController(
return GrayscalableImageGalleryContentViewController(
url: attachment.url,
caption: attachment.description,
originalData: data,
@ -91,10 +91,10 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
}
return GifvGalleryContentViewController(controller: controller, url: attachment.url, caption: attachment.description)
case .video:
return VideoGalleryContentViewController(url: attachment.url, caption: attachment.description)
return GrayscalableVideoGalleryContentViewController(url: attachment.url, caption: attachment.description)
case .audio:
// TODO: use separate content VC with audio visualization?
return VideoGalleryContentViewController(url: attachment.url, caption: attachment.description)
return GrayscalableVideoGalleryContentViewController(url: attachment.url, caption: attachment.description)
case .unknown:
return LoadingGalleryContentViewController(caption: nil) {
do {

View File

@ -151,6 +151,22 @@ class BaseMainTabBarViewController: UITabBarController, FastAccountSwitcherViewC
return false
#endif // !os(visionOS)
}
// MARK: Keyboard shortcuts
override func target(forAction action: Selector, withSender sender: Any?) -> Any? {
// This is a silly workaround for when the sidebar is focused (and therefore first responder), which,
// unfortunately, is almost always. Because the content view controller then isn't in the responder chain,
// we manually delegate to the top view controller if possible.
if action == #selector(RefreshableViewController.refresh),
let selected = selectedViewController as? NavigationControllerProtocol,
let top = selected.topViewController as? RefreshableViewController {
return top
} else {
return super.target(forAction: action, withSender: sender)
}
}
}
extension BaseMainTabBarViewController: TuskerNavigationDelegate {

View File

@ -219,6 +219,19 @@ class MainSplitViewController: UISplitViewController {
@objc func handleComposeKeyCommand() {
compose(editing: nil)
}
override func target(forAction action: Selector, withSender sender: Any?) -> Any? {
// This is a silly workaround for when the sidebar is focused (and therefore first responder), which,
// unfortunately, is almost always. Because the content view controller then isn't in the responder chain,
// we manually delegate to the top view controller if possible.
if action == #selector(RefreshableViewController.refresh),
traitCollection.horizontalSizeClass == .regular,
let top = secondaryNavController.topViewController as? RefreshableViewController {
return top
} else {
return super.target(forAction: action, withSender: sender)
}
}
}

View File

@ -33,6 +33,13 @@ final class NewMainTabBarViewController: BaseMainTabBarViewController {
private var isCompact: Bool?
@Box fileprivate var myProfileCell: UIView?
private var sidebarTapRecognizer: UITapGestureRecognizer?
private lazy var fastAccountSwitcherIndicator: UIView = {
let indicator = FastAccountSwitcherIndicatorView()
// need to explicitly set the frame to get it vertically centered
indicator.frame = CGRect(origin: .zero, size: indicator.intrinsicContentSize)
return indicator
}()
override func viewDidLoad() {
super.viewDidLoad()
@ -513,13 +520,6 @@ extension NewMainTabBarViewController: UITabBarControllerDelegate {
}
}
private var fastAccountSwitcherIndicator: UIView = {
let indicator = FastAccountSwitcherIndicatorView()
// need to explicitly set the frame to get it vertically centered
indicator.frame = CGRect(origin: .zero, size: indicator.intrinsicContentSize)
return indicator
}()
@available(iOS 18.0, *)
extension NewMainTabBarViewController: UITabBarController.Sidebar.Delegate {
func tabBarController(_ tabBarController: UITabBarController, sidebarVisibilityWillChange sidebar: UITabBarController.Sidebar, animator: any UITabBarController.Sidebar.Animating) {

View File

@ -155,7 +155,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
fetchCustomEmojiImage?.1.cancel()
case .emojiReaction(let emojiOrShortcode, let url):
iconImageView.image = nil
if let url = url.flatMap({ URL($0) }),
if let url,
fetchCustomEmojiImage?.0 != url {
fetchCustomEmojiImage?.1.cancel()
let task = Task {

View File

@ -48,7 +48,9 @@ class NotificationLoadingViewController: UIViewController {
do {
let (notification, _) = try await mastodonController.run(request)
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(notifications: [notification]) {
let container = mastodonController.persistentContainer
let context = container.viewContext
container.addAll(notifications: [notification], in: context) {
continuation.resume()
}
}

View File

@ -740,7 +740,7 @@ extension NotificationsCollectionViewController: UICollectionViewDragDelegate {
return cell.dragItemsForBeginning(session: session)
case .poll, .update:
let status = group.notifications.first!.status!
let provider = NSItemProvider(object: URL(status.url!)! as NSURL)
let provider = NSItemProvider(object: status.url! as NSURL)
let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: mastodonController.accountInfo!.id)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)

View File

@ -180,3 +180,9 @@ extension NotificationsPageViewController: StateRestorableViewController {
return currentPage.userActivity(accountID: mastodonController.accountInfo!.id)
}
}
extension NotificationsPageViewController: RefreshableViewController {
func refresh() {
(currentViewController as? RefreshableViewController)?.refresh()
}
}

View File

@ -8,7 +8,6 @@
import SwiftUI
import Pachyderm
import WebURL
struct MockStatusView: View {
@ObservedObject private var preferences = Preferences.shared
@ -136,8 +135,8 @@ private struct MockStatusCardView: UIViewRepresentable {
let view = StatusCardView()
view.isUserInteractionEnabled = false
let card = StatusCardView.CardData(
url: WebURL("https://vaccor.space/tusker")!,
image: WebURL("https://vaccor.space/tusker/img/icon.png")!,
url: URL(string: "https://vaccor.space/tusker")!,
image: URL(string: "https://vaccor.space/tusker/img/icon.png")!,
title: "Tusker",
description: "Tusker is an iOS app for Mastodon"
)

View File

@ -8,7 +8,6 @@
import SwiftUI
import UserAccounts
import WebURL
struct PrefsAccountView: View {
let account: UserAccountInfo
@ -19,12 +18,7 @@ struct PrefsAccountView: View {
VStack(alignment: .prefsAvatar) {
Text(verbatim: account.username)
.foregroundColor(.primary)
let instance = if let domain = WebURL.Domain(account.instanceURL.host!) {
domain.render(.uncheckedUnicodeString)
} else {
account.instanceURL.host!
}
Text(verbatim: instance)
Text(verbatim: account.instanceURL.host(percentEncoded: false)!)
.font(.caption)
.foregroundColor(.primary)
}

View File

@ -393,3 +393,9 @@ extension ProfileViewController: StatusBarTappableViewController {
return currentViewController.handleStatusBarTapped(xPosition: xPosition)
}
}
extension ProfileViewController: RefreshableViewController {
func refresh() {
currentViewController.refresh()
}
}

View File

@ -9,7 +9,6 @@
import UIKit
import Combine
import Pachyderm
import WebURLFoundationExtras
fileprivate let accountCell = "accountCell"
fileprivate let statusCell = "statusCell"
@ -538,7 +537,7 @@ extension SearchResultsViewController: UICollectionViewDragDelegate {
url = account.url
activity = UserActivityManager.showProfileActivity(id: id, accountID: accountInfo.id)
case .hashtag(let tag):
url = URL(tag.url)!
url = tag.url
activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: tag.name), accountID: accountInfo.id)!
case .status(let id, _):
guard let status = mastodonController.persistentContainer.status(for: id),

View File

@ -8,7 +8,6 @@
import UIKit
import Pachyderm
import WebURL
class StatusActionAccountListViewController: UIViewController {
@ -183,7 +182,7 @@ extension StatusActionAccountListViewController {
enum ActionType {
case favorite
case reblog
case emojiReaction(String, WebURL?)
case emojiReaction(String, URL?)
init?(_ groupKind: NotificationGroup.Kind) {
switch groupKind {

View File

@ -77,11 +77,25 @@ class InstanceTimelineViewController: TimelineViewController {
cell.updateUI(statusID: id, state: state, filterResult: filterResult, precomputedContent: precomputedContent)
}
override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
guard browsingEnabled else {
return false
}
return super.collectionView(collectionView, shouldSelectItemAt: indexPath)
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard browsingEnabled else { return }
super.collectionView(collectionView, didSelectItemAt: indexPath)
}
override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard browsingEnabled else {
return nil
}
return super.collectionView(collectionView, contextMenuConfigurationForItemAt: indexPath, point: point)
}
// MARK: Timeline
override func handleLoadAllError(_ error: Swift.Error) async {

View File

@ -212,3 +212,9 @@ extension TimelinesPageViewController: StateRestorableViewController {
return (currentViewController as? TimelineViewController)?.stateRestorationActivity()
}
}
extension TimelinesPageViewController: RefreshableViewController {
func refresh() {
(currentViewController as? RefreshableViewController)?.refresh()
}
}

View File

@ -9,7 +9,6 @@
import UIKit
import SafariServices
import Pachyderm
import WebURLFoundationExtras
import SwiftUI
@MainActor
@ -154,12 +153,7 @@ extension MenuActionProvider {
}
}
let shareSection: [UIMenuElement]
if let url = URL(hashtag.url) {
shareSection = actionsForURL(url, source: source)
} else {
shareSection = []
}
let shareSection = actionsForURL(hashtag.url, source: source)
return [
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
@ -375,14 +369,11 @@ extension MenuActionProvider {
}
func actionsForTrendingLink(card: Card, source: PopoverSource) -> [UIMenuElement] {
guard let url = URL(card.url) else {
return []
}
return [
openInSafariAction(url: url),
openInSafariAction(url: card.url),
createAction(identifier: "share", title: "Share…", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self else { return }
self.navigationDelegate?.showMoreOptions(forURL: url, source: source)
self.navigationDelegate?.showMoreOptions(forURL: card.url, source: source)
}),
createAction(identifier: "postlink", title: "Post this Link", systemImageName: "square.and.pencil", handler: { [weak self] _ in
guard let self = self else { return }
@ -393,7 +384,7 @@ extension MenuActionProvider {
text += title
text += ":\n"
}
text += url.absoluteString
text += card.url.absoluteString
let draft = self.mastodonController!.createDraft(text: text)
self.navigationDelegate?.compose(editing: draft)

View File

@ -30,7 +30,9 @@ class AccountDisplayAndUserNameLabel: EmojiLabel {
private func makeAttributedText(state: State) -> NSAttributedString {
let s = NSMutableAttributedString()
s.append(NSAttributedString(string: state.displayName, attributes: [
// U+2068 FIRST-STRONG ISOLATE and U+2069 POP DIRECTIONAL ISOLATE
// to prevent bidi text in the display name influencing the username
s.append(NSAttributedString(string: "\u{2068}\(state.displayName)\u{2069}", attributes: [
.font: UIFont(descriptor: baseFont.addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue,

View File

@ -8,7 +8,6 @@
import SwiftUI
import Pachyderm
import WebURLFoundationExtras
import os
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
@ -54,7 +53,7 @@ struct AccountDisplayNameView: View {
}
group.enter()
let request = ImageCache.emojis.get(URL(emoji.url)!) { (_, image) in
let request = ImageCache.emojis.get(emoji.url) { (_, image) in
defer { group.leave() }
guard let image = image else { return }

View File

@ -402,7 +402,7 @@ class AttachmentView: GIFImageView {
makeBadgeView(text: "ALT")
}
if badges.contains(.noAlt) {
makeBadgeView(text: "No ALT")
makeBadgeView(text: "NO ALT")
}
let first = stack.arrangedSubviews.first!
@ -467,12 +467,12 @@ extension AttachmentView: UIContextMenuInteractionDelegate {
return UIContextMenuConfiguration { [unowned self] () -> UIViewController? in
if self.attachment.kind == .image,
let image {
return ImageGalleryContentViewController(url: self.attachment.url, caption: nil, originalData: nil, image: image, gifController: self.gifController)
return GrayscalableImageGalleryContentViewController(url: self.attachment.url, caption: nil, originalData: nil, image: image, gifController: self.gifController)
} else if self.attachment.kind == .gifv,
let gifvView {
return GifvGalleryContentViewController(controller: gifvView.controller, url: self.attachment.url, caption: nil)
} else if self.attachment.kind == .video || self.attachment.kind == .audio {
let vc = VideoGalleryContentViewController(url: self.attachment.url, caption: nil)
let vc = GrayscalableVideoGalleryContentViewController(url: self.attachment.url, caption: nil)
vc.player.isMuted = true
return vc
} else {

View File

@ -295,7 +295,11 @@ class AttachmentsContainerView: UIView {
accessibilityElements.append(moreView)
}
self.aspectRatio = aspectRatio
self.aspectRatio = if aspectRatio.isNaN || aspectRatio.isInfinite {
16/9
} else {
aspectRatio
}
} else {
self.isHidden = true
}

View File

@ -60,9 +60,9 @@ class GifvController {
}
private func updatePresentationSizeObservation() {
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] item, _ in
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [weak self] item, _ in
DispatchQueue.main.async {
self.presentationSizeSubject.send(item.presentationSize)
self?.presentationSizeSubject.send(item.presentationSize)
}
})
}

View File

@ -8,7 +8,6 @@
import UIKit
import Pachyderm
import WebURLFoundationExtras
import os
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
@ -73,7 +72,7 @@ extension BaseEmojiLabel {
foundEmojis = true
if let image = ImageCache.emojis.get(URL(emoji.url)!)?.image {
if let image = ImageCache.emojis.get(emoji.url)?.image {
// if the image is cached, add it immediately.
// we generate the thumbnail on the main thread, because it's usually fast enough
// and the delay caused by doing it asynchronously looks works.
@ -90,7 +89,7 @@ extension BaseEmojiLabel {
// otherwise, perform the network request
group.enter()
let request = ImageCache.emojis.getFromSource(URL(emoji.url)!) { (_, image) in
let request = ImageCache.emojis.getFromSource(emoji.url) { (_, image) in
guard let image else {
group.leave()
return
@ -98,7 +97,7 @@ extension BaseEmojiLabel {
image.prepareThumbnail(of: emojiImageSize(image)) { thumbnail in
guard let thumbnail = thumbnail?.cgImage,
case let rescaled = UIImage(cgImage: thumbnail, scale: screenScale, orientation: .up),
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: URL(emoji.url)!, image: rescaled) else {
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: emoji.url, image: rescaled) else {
group.leave()
return
}

View File

@ -9,8 +9,6 @@
import UIKit
import Pachyderm
import SafariServices
import WebURL
import WebURLFoundationExtras
import Combine
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])

View File

@ -8,7 +8,6 @@
import SwiftUI
import Pachyderm
import WebURLFoundationExtras
struct CustomEmojiImageView: View {
let emoji: Emoji
@ -35,7 +34,7 @@ struct CustomEmojiImageView: View {
@MainActor
private func loadImage() {
request = ImageCache.emojis.get(URL(emoji.url)!) { (_, image) in
request = ImageCache.emojis.get(emoji.url) { (_, image) in
DispatchQueue.main.async {
self.request = nil
if let image = image {

View File

@ -12,11 +12,7 @@ import SwiftUI
import SafariServices
class ProfileFieldValueView: UIView {
weak var navigationDelegate: TuskerNavigationDelegate? {
didSet {
textView.navigationDelegate = navigationDelegate
}
}
weak var navigationDelegate: TuskerNavigationDelegate?
private static let converter = HTMLConverter(
font: .preferredFont(forTextStyle: .body),
@ -28,8 +24,9 @@ class ProfileFieldValueView: UIView {
private let account: AccountMO
private let field: Account.Field
private var link: (String, URL)?
private let textView = ContentTextView()
private let label = EmojiLabel()
private var iconView: UIView?
private var currentTargetedPreview: UITargetedPreview?
@ -42,28 +39,34 @@ class ProfileFieldValueView: UIView {
let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value))
#if os(visionOS)
textView.linkTextAttributes = [
.foregroundColor: UIColor.link
]
#else
textView.linkTextAttributes = [
.foregroundColor: UIColor.tintColor
]
#endif
textView.backgroundColor = nil
textView.isScrollEnabled = false
textView.isSelectable = false
textView.isEditable = false
textView.font = .preferredFont(forTextStyle: .body)
updateTextContainerInset()
textView.adjustsFontForContentSizeCategory = true
textView.attributedText = converted
textView.setEmojis(account.emojis, identifier: account.id)
textView.isUserInteractionEnabled = true
textView.setContentCompressionResistancePriority(.required, for: .vertical)
textView.translatesAutoresizingMaskIntoConstraints = false
addSubview(textView)
converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in
guard value != nil else { return }
if self.link == nil {
self.link = (converted.attributedSubstring(from: range).string, value as! URL)
}
#if os(visionOS)
converted.addAttribute(.foregroundColor, value: UIColor.link, range: range)
#else
converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range)
#endif
// the .link attribute in a UILabel always makes the color blue >.>
converted.removeAttribute(.link, range: range)
}
if link != nil {
label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(linkTapped)))
label.addInteraction(UIContextMenuInteraction(delegate: self))
label.isUserInteractionEnabled = true
}
label.numberOfLines = 0
label.font = .preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
label.attributedText = converted
label.setEmojis(account.emojis, identifier: account.id)
label.setContentCompressionResistancePriority(.required, for: .vertical)
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
let labelTrailingConstraint: NSLayoutConstraint
@ -80,20 +83,20 @@ class ProfileFieldValueView: UIView {
icon.isPointerInteractionEnabled = true
icon.accessibilityLabel = "Verified link"
addSubview(icon)
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
NSLayoutConstraint.activate([
icon.centerYAnchor.constraint(equalTo: textView.centerYAnchor),
icon.centerYAnchor.constraint(equalTo: label.centerYAnchor),
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
])
} else {
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor)
}
NSLayoutConstraint.activate([
textView.leadingAnchor.constraint(equalTo: leadingAnchor),
label.leadingAnchor.constraint(equalTo: leadingAnchor),
labelTrailingConstraint,
textView.topAnchor.constraint(equalTo: topAnchor),
textView.bottomAnchor.constraint(equalTo: bottomAnchor),
label.topAnchor.constraint(equalTo: topAnchor),
label.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
@ -102,36 +105,37 @@ class ProfileFieldValueView: UIView {
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
var size = textView.sizeThatFits(size)
var size = label.sizeThatFits(size)
if let iconView {
size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width
}
return size
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory {
updateTextContainerInset()
}
}
private func updateTextContainerInset() {
// blergh
switch traitCollection.preferredContentSizeCategory {
case .extraSmall:
textView.textContainerInset = UIEdgeInsets(top: 4, left: 0, bottom: 0, right: 0)
case .small:
textView.textContainerInset = UIEdgeInsets(top: 3, left: 0, bottom: 0, right: 0)
case .medium, .large:
textView.textContainerInset = UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0)
default:
textView.textContainerInset = .zero
}
}
func setTextAlignment(_ alignment: NSTextAlignment) {
textView.textAlignment = alignment
label.textAlignment = alignment
}
func getHashtagOrURL() -> (Hashtag?, URL)? {
guard let (text, url) = link else {
return nil
}
if text.starts(with: "#") {
return (Hashtag(name: String(text.dropFirst()), url: url), url)
} else {
return (nil, url)
}
}
@objc private func linkTapped() {
guard let (hashtag, url) = getHashtagOrURL() else {
return
}
if let hashtag {
navigationDelegate?.selected(tag: hashtag)
} else {
navigationDelegate?.selected(url: url)
}
}
@objc private func verifiedIconTapped() {
@ -141,7 +145,7 @@ class ProfileFieldValueView: UIView {
let view = ProfileFieldVerificationView(
acct: account.acct,
verifiedAt: field.verifiedAt!,
linkText: textView.text ?? "",
linkText: label.text ?? "",
navigationDelegate: navigationDelegate
)
let host = UIHostingController(rootView: view)
@ -165,3 +169,49 @@ class ProfileFieldValueView: UIView {
navigationDelegate.present(toPresent, animated: true)
}
}
extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionProvider {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
guard let (hashtag, url) = getHashtagOrURL(),
let navigationDelegate else {
return nil
}
if let hashtag {
return UIContextMenuConfiguration {
HashtagTimelineViewController(for: hashtag, mastodonController: navigationDelegate.apiController)
} actionProvider: { _ in
UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self)))
}
} else {
return UIContextMenuConfiguration {
let vc = SFSafariViewController(url: url)
#if !os(visionOS)
vc.preferredControlTintColor = Preferences.shared.accentColor.color
#endif
return vc
} actionProvider: { _ in
UIMenu(children: self.actionsForURL(url, source: .view(self)))
}
}
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, highlightPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? {
var rect = label.textRect(forBounds: label.bounds, limitedToNumberOfLines: 0)
// the rect should be vertically centered, but textRect doesn't seem to take the label's vertical alignment into account
rect.origin.x = 0
rect.origin.y = (bounds.height - rect.height) / 2
let parameters = UIPreviewParameters(textLineRects: [rect as NSValue])
let preview = UITargetedPreview(view: label, parameters: parameters)
currentTargetedPreview = preview
return preview
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, dismissalPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? {
return currentTargetedPreview
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: navigationDelegate!)
}
}

View File

@ -9,8 +9,6 @@
import UIKit
import Pachyderm
import SafariServices
import WebURL
import WebURLFoundationExtras
import HTMLStreamer
class StatusCardView: UIView {
@ -184,14 +182,14 @@ class StatusCardView: UIView {
if sensitive {
if let blurhash = card.blurhash {
imageView.blurImage = false
imageView.showOnlyBlurHash(blurhash, for: URL(image)!)
imageView.showOnlyBlurHash(blurhash, for: image)
} else {
// if we don't have a blurhash, load the image and show it behind a blur
imageView.blurImage = true
imageView.update(for: URL(image), blurhash: nil)
imageView.update(for: image, blurhash: nil)
}
} else {
imageView.update(for: URL(image), blurhash: card.blurhash)
imageView.update(for: image, blurhash: card.blurhash)
}
imageView.isHidden = false
leadingSpacer.isHidden = true
@ -210,8 +208,8 @@ class StatusCardView: UIView {
descriptionLabel.text = description
descriptionLabel.isHidden = description.isEmpty
if let host = card.url.host {
domainLabel.text = host.serialized
if let host = card.url.host(percentEncoded: false) {
domainLabel.text = host
domainLabel.isHidden = false
} else {
domainLabel.isHidden = true
@ -238,7 +236,7 @@ class StatusCardView: UIView {
setNeedsDisplay()
if let card = card, let delegate = navigationDelegate {
delegate.selected(url: URL(card.url)!)
delegate.selected(url: card.url)
}
}
@ -248,8 +246,8 @@ class StatusCardView: UIView {
}
struct CardData: Equatable {
let url: WebURL
let image: WebURL?
let url: URL
let image: URL?
let title: String
let description: String
let blurhash: String?
@ -262,7 +260,7 @@ class StatusCardView: UIView {
self.blurhash = card.blurhash
}
init(url: WebURL, image: WebURL? = nil, title: String, description: String, blurhash: String? = nil) {
init(url: URL, image: URL? = nil, title: String, description: String, blurhash: String? = nil) {
self.url = url
self.image = image
self.title = title
@ -278,13 +276,13 @@ extension StatusCardView: UIContextMenuInteractionDelegate {
guard let card = card else { return nil }
return UIContextMenuConfiguration(identifier: nil) {
let vc = SFSafariViewController(url: URL(card.url)!)
let vc = SFSafariViewController(url: card.url)
#if !os(visionOS)
vc.preferredControlTintColor = Preferences.shared.accentColor.color
#endif
return vc
} actionProvider: { (_) in
let actions = self.actionProvider?.actionsForURL(URL(card.url)!, source: .view(self)) ?? []
let actions = self.actionProvider?.actionsForURL(card.url, source: .view(self)) ?? []
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
}
}

View File

@ -8,7 +8,6 @@
import UIKit
import Pachyderm
import WebURLFoundationExtras
class StatusContentTextView: ContentTextView {
@ -26,7 +25,7 @@ class StatusContentTextView: ContentTextView {
let mastodonController = mastodonController,
let status = mastodonController.persistentContainer.status(for: statusID) {
mention = status.mentions.first { (mention) in
url.host == mention.url.host!.serialized && (
url.host() == mention.url.host() && (
text.dropFirst() == mention.username // Mastodon and Pleroma include @ in the text
|| text.dropFirst() == mention.acct // Misskey includes @ and uses the whole acct
|| text == mention.username // GNU Social does not include the @ in the text, so we don't need to drop it
@ -44,7 +43,7 @@ class StatusContentTextView: ContentTextView {
let mastodonController = mastodonController,
let status = mastodonController.persistentContainer.status(for: statusID) {
hashtag = status.hashtags.first { (hashtag) in
URL(hashtag.url) == url
hashtag.url == url
}
} else {
hashtag = nil

View File

@ -9,8 +9,8 @@
// Configuration settings file format documentation can be found at:
// https://help.apple.com/xcode/#/dev745c5c974
MARKETING_VERSION = 2024.4
CURRENT_PROJECT_VERSION = 136
MARKETING_VERSION = 2024.5
CURRENT_PROJECT_VERSION = 139
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev