Compare commits
45 Commits
68c3affacf
...
dd82283341
Author | SHA1 | Date |
---|---|---|
Shadowfacts | dd82283341 | |
Shadowfacts | af2d9e7eb8 | |
Shadowfacts | 06ad46e639 | |
Shadowfacts | 71f97d41c4 | |
Shadowfacts | df131f32c6 | |
Shadowfacts | 77dece36d0 | |
Shadowfacts | 1a767ff910 | |
Shadowfacts | 220c8050b1 | |
Shadowfacts | d4fa9c96e8 | |
Shadowfacts | 22b5d62ba1 | |
Shadowfacts | b9bdd29986 | |
Shadowfacts | f848bbf7c4 | |
Shadowfacts | 0fe9edfdbc | |
Shadowfacts | 6d2830cf78 | |
Shadowfacts | 7294ff6e1a | |
Shadowfacts | 3fd62552b3 | |
Shadowfacts | fa5abc27f7 | |
Shadowfacts | ccc47e204d | |
Shadowfacts | bf3f735062 | |
Shadowfacts | de0198946e | |
Shadowfacts | 072a77b58e | |
Shadowfacts | eb7fe22863 | |
Shadowfacts | f1511039ef | |
Shadowfacts | 5c479e3bf0 | |
Shadowfacts | 0413f326a0 | |
Shadowfacts | 9d1c3f1410 | |
Shadowfacts | 802a0ac9ba | |
Shadowfacts | 9da986e3b8 | |
Shadowfacts | e6a5b899be | |
Shadowfacts | 60bf3b2e33 | |
Shadowfacts | b465838b71 | |
Shadowfacts | 21bd716844 | |
Shadowfacts | 523fb91b21 | |
Shadowfacts | d8bf770902 | |
Shadowfacts | 10aa32d9cc | |
Shadowfacts | 7474969969 | |
Shadowfacts | 319b5458fc | |
Shadowfacts | f7304a011c | |
Shadowfacts | 94dc5d3177 | |
Shadowfacts | 6d692c2730 | |
Shadowfacts | d0f8691560 | |
Shadowfacts | 9a43ab5a13 | |
Shadowfacts | 01124b76a3 | |
Shadowfacts | 7600954f4b | |
Shadowfacts | 5a5c67e445 |
39
CHANGELOG.md
39
CHANGELOG.md
|
@ -1,5 +1,44 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2022.1 (45)
|
||||||
|
Features/Improvements:
|
||||||
|
- iPhone: Temporarily hide the Compose screen by swiping down to access the rest of the applies
|
||||||
|
- Add Block, Domain Block, and Mute actions to accounts
|
||||||
|
- Don't change scroll position when switch sections in the Profile screen
|
||||||
|
- Use URL keyboard in the instance selector and clarify that you can enter any domain
|
||||||
|
- iPad: Add context menu action for deleting lists in sidebar
|
||||||
|
- Tweak conditions in which profile fields are shown in a single column, rather than two
|
||||||
|
- Convert wide color gamut images to sRGB before uploading
|
||||||
|
- The Mastodon backend does not support wide-gamut images and does a poor job of conversion, so the conversion is performed locally
|
||||||
|
- Focus content warning field immediately when CW button is pressed
|
||||||
|
- Move focus to main text field when return key is pressed while editing the content warning
|
||||||
|
- Make GIF attachments animate on the Compose screen
|
||||||
|
- VoiceOver: Make profile fields accessible
|
||||||
|
- VoiceOver: Only read content warning and not content for CW'd posts
|
||||||
|
- VoiceOver: Expand collapsed posts when performing double-tap
|
||||||
|
- VoiceOver: Announce visibility of followers-only & direct posts
|
||||||
|
- VoiceOver: Make Compose toolbar accessible
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix tapping links in profile fields
|
||||||
|
- Fix crash when creating/editing list fails
|
||||||
|
- Fix renaming a list not updating it elsewhere in the UI
|
||||||
|
- Fix instance-local/everywhere scope selector in Profile Directory being flipped
|
||||||
|
- Fix context menu previews of attachments not working
|
||||||
|
- Fix caret not scrolling into view when opening Compose
|
||||||
|
- Fix cells in the Drafts list being too small to tap
|
||||||
|
- Fix refresh failing when initial load failed
|
||||||
|
- Fix video controls in the gallery being too close to the edge of the screen
|
||||||
|
- Fix error when decoding malformed notifications
|
||||||
|
- Fix reblog with visibility not being available on Hometown instances
|
||||||
|
- Fix visibility dropdown being shown in confirm reblog alert even when unavailable
|
||||||
|
- Fix confirm reblog alert not adjusting to Dynamic Type
|
||||||
|
- Fix layout issues with replies on Compose screen
|
||||||
|
- macOS: Fix GIFs dragged from Finder posting static images
|
||||||
|
|
||||||
|
Known Issues:
|
||||||
|
- Drag/drop to add attachments when composting a post does not work
|
||||||
|
|
||||||
## 2022.1 (44)
|
## 2022.1 (44)
|
||||||
Features/Improvements:
|
Features/Improvements:
|
||||||
- Dynamic Type support
|
- Dynamic Type support
|
||||||
|
|
|
@ -82,14 +82,14 @@ public final class Account: AccountProtocol, Decodable {
|
||||||
return Request<Empty>(method: .delete, path: "/api/v1/suggestions/\(account.id)")
|
return Request<Empty>(method: .delete, path: "/api/v1/suggestions/\(account.id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFollowers(_ account: Account, range: RequestRange = .default) -> Request<[Account]> {
|
public static func getFollowers(_ accountID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(account.id)/followers")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(accountID)/followers")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFollowing(_ account: Account, range: RequestRange = .default) -> Request<[Account]> {
|
public static func getFollowing(_ accountID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(account.id)/following")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(accountID)/following")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
@ -112,22 +112,22 @@ public final class Account: AccountProtocol, Decodable {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unfollow")
|
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unfollow")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func block(_ account: Account) -> Request<Relationship> {
|
public static func block(_ accountID: String) -> Request<Relationship> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/block")
|
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/block")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unblock(_ account: Account) -> Request<Relationship> {
|
public static func unblock(_ accountID: String) -> Request<Relationship> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/unblock")
|
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unblock")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func mute(_ account: Account, notifications: Bool? = nil) -> Request<Relationship> {
|
public static func mute(_ accountID: String, notifications: Bool? = nil) -> Request<Relationship> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: ParametersBody([
|
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/mute", body: ParametersBody([
|
||||||
"notifications" => notifications
|
"notifications" => notifications
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func unmute(_ account: Account) -> Request<Relationship> {
|
public static func unmute(_ accountID: String) -> Request<Relationship> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/unmute")
|
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unmute")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getLists(_ account: Account) -> Request<[List]> {
|
public static func getLists(_ account: Account) -> Request<[List]> {
|
||||||
|
|
|
@ -30,11 +30,7 @@ public class Notification: Decodable {
|
||||||
}
|
}
|
||||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||||
self.account = try container.decode(Account.self, forKey: .account)
|
self.account = try container.decode(Account.self, forKey: .account)
|
||||||
if container.contains(.status) {
|
self.status = try container.decodeIfPresent(Status.self, forKey: .status)
|
||||||
self.status = try container.decode(Status.self, forKey: .status)
|
|
||||||
} else {
|
|
||||||
self.status = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
/*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/config/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
|
@ -0,0 +1,31 @@
|
||||||
|
// swift-tools-version: 5.7
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "Duckable",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v15),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
.library(
|
||||||
|
name: "Duckable",
|
||||||
|
targets: ["Duckable"]),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
// Dependencies declare other packages that this package depends on.
|
||||||
|
// .package(url: /* package url */, from: "1.0.0"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||||
|
// Targets can depend on other targets in this package, and on products in packages this package depends on.
|
||||||
|
.target(
|
||||||
|
name: "Duckable",
|
||||||
|
dependencies: []),
|
||||||
|
.testTarget(
|
||||||
|
name: "DuckableTests",
|
||||||
|
dependencies: ["Duckable"]),
|
||||||
|
]
|
||||||
|
)
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Duckable
|
||||||
|
|
||||||
|
A package that allows modally-presented view controllers to be 'ducked' to make the content behind them accessible (à la Mail.app).
|
|
@ -0,0 +1,44 @@
|
||||||
|
//
|
||||||
|
// API.swift
|
||||||
|
// Duckable
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/7/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
public protocol DuckableViewController: UIViewController {
|
||||||
|
var duckableDelegate: DuckableViewControllerDelegate? { get set }
|
||||||
|
|
||||||
|
func duckableViewControllerMayAttemptToDuck()
|
||||||
|
|
||||||
|
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat)
|
||||||
|
|
||||||
|
func duckableViewControllerDidFinishAnimatingDuck()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DuckableViewController {
|
||||||
|
public func duckableViewControllerMayAttemptToDuck() {}
|
||||||
|
public func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {}
|
||||||
|
public func duckableViewControllerDidFinishAnimatingDuck() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol DuckableViewControllerDelegate: AnyObject {
|
||||||
|
func duckableViewControllerWillDismiss(animated: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UIViewController {
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
public func presentDuckable(_ viewController: DuckableViewController) -> Bool {
|
||||||
|
var cur: UIViewController? = self
|
||||||
|
while let vc = cur {
|
||||||
|
if let container = vc as? DuckableContainerViewController {
|
||||||
|
container.presentDuckable(viewController, animated: true, completion: nil)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
cur = vc.parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
//
|
||||||
|
// DetentIdentifier.swift
|
||||||
|
// Duckable
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/7/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UISheetPresentationController.Detent.Identifier {
|
||||||
|
static let bottom = Self("\(Bundle.main.bundleIdentifier!).bottom")
|
||||||
|
}
|
|
@ -0,0 +1,89 @@
|
||||||
|
//
|
||||||
|
// DuckAnimationController.swift
|
||||||
|
// Duckable
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/7/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
|
||||||
|
let owner: DuckableContainerViewController
|
||||||
|
let needsShrinkAnimation: Bool
|
||||||
|
|
||||||
|
init(owner: DuckableContainerViewController, needsShrinkAnimation: Bool) {
|
||||||
|
self.owner = owner
|
||||||
|
self.needsShrinkAnimation = needsShrinkAnimation
|
||||||
|
}
|
||||||
|
|
||||||
|
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||||
|
return 0.2
|
||||||
|
}
|
||||||
|
|
||||||
|
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||||
|
guard case .ducked(let duckable, placeholder: let placeholder) = owner.state,
|
||||||
|
let presented = transitionContext.viewController(forKey: .from) else {
|
||||||
|
transitionContext.completeTransition(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard transitionContext.isAnimated else {
|
||||||
|
transitionContext.completeTransition(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let container = transitionContext.containerView
|
||||||
|
|
||||||
|
|
||||||
|
if needsShrinkAnimation {
|
||||||
|
|
||||||
|
duckable.duckableViewControllerWillAnimateDuck(withDuration: 0.2, afterDelay: 0.2)
|
||||||
|
|
||||||
|
let presentedFrameInContainer = container.convert(presented.view.bounds, from: presented.view)
|
||||||
|
let heightToSlide = container.bounds.height - container.safeAreaInsets.bottom - detentHeight - presentedFrameInContainer.minY
|
||||||
|
|
||||||
|
let slideAnimator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1)
|
||||||
|
slideAnimator.addAnimations {
|
||||||
|
container.transform = CGAffineTransform(translationX: 0, y: heightToSlide + 10)
|
||||||
|
}
|
||||||
|
slideAnimator.addCompletion { _ in
|
||||||
|
duckable.duckableViewControllerDidFinishAnimatingDuck()
|
||||||
|
transitionContext.completeTransition(true)
|
||||||
|
}
|
||||||
|
slideAnimator.startAnimation()
|
||||||
|
|
||||||
|
placeholder.view.transform = CGAffineTransform(translationX: 0, y: 10)
|
||||||
|
let bounceAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||||
|
placeholder.view.transform = .identity
|
||||||
|
container.transform = CGAffineTransform(translationX: 0, y: heightToSlide)
|
||||||
|
}
|
||||||
|
bounceAnimator.startAnimation(afterDelay: 0.3)
|
||||||
|
|
||||||
|
let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||||
|
presented.view.layer.opacity = 0
|
||||||
|
}
|
||||||
|
fadeAnimator.startAnimation(afterDelay: 0.3)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
duckable.duckableViewControllerWillAnimateDuck(withDuration: 0.2, afterDelay: 0)
|
||||||
|
|
||||||
|
placeholder.view.transform = CGAffineTransform(translationX: 0, y: 10)
|
||||||
|
let bounceAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||||
|
placeholder.view.transform = .identity
|
||||||
|
container.transform = CGAffineTransform(translationX: 0, y: -10)
|
||||||
|
}
|
||||||
|
bounceAnimator.startAnimation(afterDelay: 0.2)
|
||||||
|
|
||||||
|
let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||||
|
presented.view.layer.opacity = 0
|
||||||
|
}
|
||||||
|
fadeAnimator.addCompletion { _ in
|
||||||
|
duckable.duckableViewControllerDidFinishAnimatingDuck()
|
||||||
|
transitionContext.completeTransition(true)
|
||||||
|
}
|
||||||
|
fadeAnimator.startAnimation(afterDelay: 0.2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,217 @@
|
||||||
|
//
|
||||||
|
// DuckableContainerViewController.swift
|
||||||
|
// Duckable
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/7/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
let duckedCornerRadius: CGFloat = 10
|
||||||
|
let detentHeight: CGFloat = 44
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
public class DuckableContainerViewController: UIViewController, DuckableViewControllerDelegate {
|
||||||
|
|
||||||
|
public let child: UIViewController
|
||||||
|
private var bottomConstraint: NSLayoutConstraint!
|
||||||
|
private(set) var state = State.idle
|
||||||
|
|
||||||
|
public init(child: UIViewController) {
|
||||||
|
self.child = child
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
swizzleSheetController()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
view.backgroundColor = .black
|
||||||
|
|
||||||
|
child.beginAppearanceTransition(true, animated: false)
|
||||||
|
addChild(child)
|
||||||
|
child.didMove(toParent: self)
|
||||||
|
child.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(child.view)
|
||||||
|
child.endAppearanceTransition()
|
||||||
|
|
||||||
|
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
child.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
child.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
child.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
bottomConstraint,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
|
||||||
|
guard case .idle = state else {
|
||||||
|
if animated,
|
||||||
|
case .ducked(_, placeholder: let placeholder) = state {
|
||||||
|
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||||
|
let origConstant = placeholder.topConstraint.constant
|
||||||
|
UIView.animateKeyframes(withDuration: 0.4, delay: 0) {
|
||||||
|
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
|
||||||
|
placeholder.topConstraint.constant = origConstant - 20
|
||||||
|
self.view.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) {
|
||||||
|
placeholder.topConstraint.constant = origConstant
|
||||||
|
self.view.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = .presentingDucked(viewController, isFirstPresentation: true)
|
||||||
|
doPresentDuckable(viewController, animated: animated, completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
|
||||||
|
viewController.duckableDelegate = self
|
||||||
|
let nav = UINavigationController(rootViewController: viewController)
|
||||||
|
nav.modalPresentationStyle = .custom
|
||||||
|
nav.transitioningDelegate = self
|
||||||
|
present(nav, animated: animated) {
|
||||||
|
self.bottomConstraint.isActive = false
|
||||||
|
self.bottomConstraint = self.child.view.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight - 4)
|
||||||
|
self.bottomConstraint.isActive = true
|
||||||
|
completion?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func duckableViewControllerWillDismiss(animated: Bool) {
|
||||||
|
state = .idle
|
||||||
|
bottomConstraint.isActive = false
|
||||||
|
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||||
|
bottomConstraint.isActive = true
|
||||||
|
child.view.layer.cornerRadius = 0
|
||||||
|
setOverrideTraitCollection(nil, forChild: child)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createPlaceholderForDuckedViewController(_ viewController: DuckableViewController) -> DuckedPlaceholderViewController {
|
||||||
|
let placeholder = DuckedPlaceholderViewController(for: viewController, owner: self)
|
||||||
|
placeholder.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
placeholder.beginAppearanceTransition(true, animated: false)
|
||||||
|
self.addChild(placeholder)
|
||||||
|
placeholder.didMove(toParent: self)
|
||||||
|
self.view.addSubview(placeholder.view)
|
||||||
|
placeholder.endAppearanceTransition()
|
||||||
|
|
||||||
|
let placeholderTopConstraint = placeholder.view.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight)
|
||||||
|
placeholder.topConstraint = placeholderTopConstraint
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
placeholder.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
|
||||||
|
placeholder.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
|
||||||
|
placeholder.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
|
||||||
|
placeholderTopConstraint
|
||||||
|
])
|
||||||
|
|
||||||
|
// otherwise the layout changes get lumped in with the system animation
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
self.view.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
return placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
func duckViewController() {
|
||||||
|
guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let placeholder = createPlaceholderForDuckedViewController(viewController)
|
||||||
|
state = .ducked(viewController, placeholder: placeholder)
|
||||||
|
child.view.layer.cornerRadius = duckedCornerRadius
|
||||||
|
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
||||||
|
child.view.layer.masksToBounds = true
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func unduckViewController() {
|
||||||
|
guard case .ducked(let viewController, placeholder: let placeholder) = state else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = .presentingDucked(viewController, isFirstPresentation: false)
|
||||||
|
doPresentDuckable(viewController, animated: true) {
|
||||||
|
placeholder.view.removeFromSuperview()
|
||||||
|
placeholder.willMove(toParent: nil)
|
||||||
|
placeholder.removeFromParent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sheetOffsetDidChange() {
|
||||||
|
if case .presentingDucked(let duckable, isFirstPresentation: _) = state {
|
||||||
|
duckable.duckableViewControllerMayAttemptToDuck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
case idle
|
||||||
|
case presentingDucked(DuckableViewController, isFirstPresentation: Bool)
|
||||||
|
case ducked(DuckableViewController, placeholder: DuckedPlaceholderViewController)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
extension DuckableContainerViewController: UIViewControllerTransitioningDelegate {
|
||||||
|
public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
|
||||||
|
let controller = UISheetPresentationController(presentedViewController: presented, presenting: presenting)
|
||||||
|
controller.delegate = self
|
||||||
|
controller.prefersGrabberVisible = true
|
||||||
|
controller.selectedDetentIdentifier = .large
|
||||||
|
controller.largestUndimmedDetentIdentifier = .bottom
|
||||||
|
controller.detents = [
|
||||||
|
.custom(identifier: .bottom, resolver: { context in
|
||||||
|
return detentHeight
|
||||||
|
}),
|
||||||
|
.large(),
|
||||||
|
]
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||||
|
if case .ducked(_, placeholder: _) = state {
|
||||||
|
return DuckAnimationController(
|
||||||
|
owner: self,
|
||||||
|
needsShrinkAnimation: isDetentChangingDueToGrabberAction
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
extension DuckableContainerViewController: UISheetPresentationControllerDelegate {
|
||||||
|
public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {
|
||||||
|
let snapshot = child.view.snapshotView(afterScreenUpdates: false)!
|
||||||
|
snapshot.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
self.view.addSubview(snapshot)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
snapshot.leadingAnchor.constraint(equalTo: child.view.leadingAnchor),
|
||||||
|
snapshot.trailingAnchor.constraint(equalTo: child.view.trailingAnchor),
|
||||||
|
snapshot.topAnchor.constraint(equalTo: child.view.topAnchor),
|
||||||
|
snapshot.bottomAnchor.constraint(equalTo: child.view.bottomAnchor),
|
||||||
|
])
|
||||||
|
setOverrideTraitCollection(UITraitCollection(userInterfaceLevel: .elevated), forChild: child)
|
||||||
|
transitionCoordinator!.animate { context in
|
||||||
|
snapshot.layer.opacity = 0
|
||||||
|
} completion: { _ in
|
||||||
|
snapshot.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) {
|
||||||
|
if sheetPresentationController.selectedDetentIdentifier == .bottom {
|
||||||
|
duckViewController()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,71 @@
|
||||||
|
//
|
||||||
|
// DuckedPlaceholderView.swift
|
||||||
|
// Duckable
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/7/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
class DuckedPlaceholderViewController: UIViewController {
|
||||||
|
private unowned let owner: DuckableContainerViewController
|
||||||
|
private let navBar = UINavigationBar()
|
||||||
|
|
||||||
|
var topConstraint: NSLayoutConstraint!
|
||||||
|
|
||||||
|
init(for duckableViewController: DuckableViewController, owner: DuckableContainerViewController) {
|
||||||
|
self.owner = owner
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
let item = UINavigationItem()
|
||||||
|
item.title = duckableViewController.navigationItem.title
|
||||||
|
item.titleView = duckableViewController.navigationItem.titleView
|
||||||
|
navBar.setItems([item], animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
setBackgroundColor()
|
||||||
|
view.layer.cornerRadius = duckedCornerRadius
|
||||||
|
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||||
|
view.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
view.layer.shadowOpacity = 0.05
|
||||||
|
|
||||||
|
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(placeholderTapped)))
|
||||||
|
|
||||||
|
let appearance = UINavigationBarAppearance()
|
||||||
|
appearance.configureWithTransparentBackground()
|
||||||
|
navBar.standardAppearance = appearance
|
||||||
|
navBar.isUserInteractionEnabled = false
|
||||||
|
navBar.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(navBar)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
navBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
navBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
navBar.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
|
super.traitCollectionDidChange(previousTraitCollection)
|
||||||
|
|
||||||
|
setBackgroundColor()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setBackgroundColor() {
|
||||||
|
// when just using .systemBackground and setting the override trait collection for the placeholder VC,
|
||||||
|
// the color doesn't change until after the dismiss animation occurs (but only when tapping the grabber to duck, not when swiping)
|
||||||
|
view.backgroundColor = .systemBackground.resolvedColor(with: UITraitCollection(traitsFrom: [traitCollection, UITraitCollection(userInterfaceLevel: .elevated)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func placeholderTapped() {
|
||||||
|
owner.unduckViewController()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
//
|
||||||
|
// Swizzler.swift
|
||||||
|
// Duckable
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/7/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import os.log
|
||||||
|
|
||||||
|
private var hasInitialized = false
|
||||||
|
var isDetentChangingDueToGrabberAction = false
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
func swizzleSheetController() {
|
||||||
|
guard !hasInitialized else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasInitialized = true
|
||||||
|
|
||||||
|
var originalIMP: IMP?
|
||||||
|
let imp = imp_implementationWithBlock({ (self: UISheetPresentationController, param: AnyObject) in
|
||||||
|
let original = unsafeBitCast(originalIMP!, to: (@convention(c) (UISheetPresentationController, AnyObject) -> Void).self)
|
||||||
|
isDetentChangingDueToGrabberAction = true
|
||||||
|
original(self, param)
|
||||||
|
isDetentChangingDueToGrabberAction = false
|
||||||
|
} as @convention(block) (UISheetPresentationController, AnyObject) -> Void)
|
||||||
|
let sel = [":", "PrimaryAction", "GrabberDidTrigger", "dropShadowView", "_"].reversed().joined()
|
||||||
|
originalIMP = class_replaceMethod(UISheetPresentationController.self, Selector(sel), imp, "v@:@")
|
||||||
|
if originalIMP == nil {
|
||||||
|
os_log(.fault, log: .default, "Unable to initialize Duckable grabber tap hook")
|
||||||
|
}
|
||||||
|
}
|
|
@ -76,10 +76,6 @@
|
||||||
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */; };
|
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */; };
|
||||||
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */; };
|
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */; };
|
||||||
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627FF75217E923E00CC0648 /* DraftsManager.swift */; };
|
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627FF75217E923E00CC0648 /* DraftsManager.swift */; };
|
||||||
D627FF79217E950100CC0648 /* DraftsTableViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D627FF78217E950100CC0648 /* DraftsTableViewController.xib */; };
|
|
||||||
D627FF7B217E951500CC0648 /* DraftsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627FF7A217E951500CC0648 /* DraftsTableViewController.swift */; };
|
|
||||||
D627FF7D217E958900CC0648 /* DraftTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D627FF7C217E958900CC0648 /* DraftTableViewCell.xib */; };
|
|
||||||
D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627FF7E217E95E000CC0648 /* DraftTableViewCell.swift */; };
|
|
||||||
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6285B5221EA708700FE4B39 /* StatusFormat.swift */; };
|
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6285B5221EA708700FE4B39 /* StatusFormat.swift */; };
|
||||||
D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6289E83217B795D0003D1D7 /* LargeImageViewController.xib */; };
|
D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6289E83217B795D0003D1D7 /* LargeImageViewController.xib */; };
|
||||||
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2421217AA7E1005076CC /* UserActivityManager.swift */; };
|
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2421217AA7E1005076CC /* UserActivityManager.swift */; };
|
||||||
|
@ -152,6 +148,7 @@
|
||||||
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; };
|
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; };
|
||||||
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; };
|
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; };
|
||||||
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; };
|
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; };
|
||||||
|
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */; };
|
||||||
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; };
|
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; };
|
||||||
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; };
|
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; };
|
||||||
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; };
|
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; };
|
||||||
|
@ -254,6 +251,10 @@
|
||||||
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */; };
|
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */; };
|
||||||
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; };
|
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; };
|
||||||
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; };
|
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; };
|
||||||
|
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; };
|
||||||
|
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; };
|
||||||
|
D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA248291C6118002F4D01 /* DraftsView.swift */; };
|
||||||
|
D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */; };
|
||||||
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; };
|
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; };
|
||||||
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; };
|
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; };
|
||||||
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
|
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
|
||||||
|
@ -270,7 +271,6 @@
|
||||||
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
|
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
|
||||||
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; };
|
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; };
|
||||||
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; };
|
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; };
|
||||||
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */; };
|
|
||||||
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; };
|
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; };
|
||||||
D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; };
|
D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; };
|
||||||
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */; };
|
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */; };
|
||||||
|
@ -309,6 +309,13 @@
|
||||||
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
|
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
|
||||||
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; };
|
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; };
|
||||||
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; };
|
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; };
|
||||||
|
D6F6A54C291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54B291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift */; };
|
||||||
|
D6F6A54E291EF7E100F496A8 /* EditListSearchFollowingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54D291EF7E100F496A8 /* EditListSearchFollowingViewController.swift */; };
|
||||||
|
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54F291F058600F496A8 /* CreateListService.swift */; };
|
||||||
|
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A551291F098700F496A8 /* RenameListService.swift */; };
|
||||||
|
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A553291F0D9600F496A8 /* DeleteListService.swift */; };
|
||||||
|
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */; };
|
||||||
|
D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A5582920676800F496A8 /* ComposeToolbar.swift */; };
|
||||||
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; };
|
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; };
|
||||||
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; };
|
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
@ -429,10 +436,6 @@
|
||||||
D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTimelineViewController.swift; sourceTree = "<group>"; };
|
D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTimelineViewController.swift; sourceTree = "<group>"; };
|
||||||
D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListAccountsViewController.swift; sourceTree = "<group>"; };
|
D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListAccountsViewController.swift; sourceTree = "<group>"; };
|
||||||
D627FF75217E923E00CC0648 /* DraftsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsManager.swift; sourceTree = "<group>"; };
|
D627FF75217E923E00CC0648 /* DraftsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsManager.swift; sourceTree = "<group>"; };
|
||||||
D627FF78217E950100CC0648 /* DraftsTableViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = DraftsTableViewController.xib; sourceTree = "<group>"; };
|
|
||||||
D627FF7A217E951500CC0648 /* DraftsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsTableViewController.swift; sourceTree = "<group>"; };
|
|
||||||
D627FF7C217E958900CC0648 /* DraftTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = DraftTableViewCell.xib; sourceTree = "<group>"; };
|
|
||||||
D627FF7E217E95E000CC0648 /* DraftTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftTableViewCell.swift; sourceTree = "<group>"; };
|
|
||||||
D6285B5221EA708700FE4B39 /* StatusFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFormat.swift; sourceTree = "<group>"; };
|
D6285B5221EA708700FE4B39 /* StatusFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFormat.swift; sourceTree = "<group>"; };
|
||||||
D6289E83217B795D0003D1D7 /* LargeImageViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LargeImageViewController.xib; sourceTree = "<group>"; };
|
D6289E83217B795D0003D1D7 /* LargeImageViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LargeImageViewController.xib; sourceTree = "<group>"; };
|
||||||
D62D2421217AA7E1005076CC /* UserActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityManager.swift; sourceTree = "<group>"; };
|
D62D2421217AA7E1005076CC /* UserActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityManager.swift; sourceTree = "<group>"; };
|
||||||
|
@ -507,6 +510,7 @@
|
||||||
D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; };
|
D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; };
|
||||||
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
|
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
|
||||||
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; };
|
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPicker.swift; sourceTree = "<group>"; };
|
||||||
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; sourceTree = "<group>"; };
|
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; sourceTree = "<group>"; };
|
||||||
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
||||||
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
|
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
|
||||||
|
@ -609,6 +613,10 @@
|
||||||
D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsMode.swift; sourceTree = "<group>"; };
|
D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsMode.swift; sourceTree = "<group>"; };
|
||||||
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; };
|
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; };
|
||||||
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; };
|
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = "<group>"; };
|
||||||
|
D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = "<group>"; };
|
||||||
|
D6BEA248291C6118002F4D01 /* DraftsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsView.swift; sourceTree = "<group>"; };
|
||||||
|
D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertWithData.swift; sourceTree = "<group>"; };
|
||||||
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
|
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
|
||||||
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = "<group>"; };
|
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = "<group>"; };
|
||||||
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
|
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
|
||||||
|
@ -625,7 +633,6 @@
|
||||||
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
|
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
|
||||||
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
|
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
|
||||||
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = "<group>"; };
|
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = "<group>"; };
|
||||||
D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeContainerView.swift; sourceTree = "<group>"; };
|
|
||||||
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = "<group>"; };
|
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = "<group>"; };
|
||||||
D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = "<group>"; };
|
D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = "<group>"; };
|
||||||
D6D4DDCC212518A000E1C4BB /* Tusker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tusker.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
D6D4DDCC212518A000E1C4BB /* Tusker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tusker.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
@ -673,6 +680,13 @@
|
||||||
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; };
|
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; };
|
||||||
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; };
|
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; };
|
||||||
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = "<group>"; };
|
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = "<group>"; };
|
||||||
|
D6F6A54B291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListSearchResultsContainerViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D6F6A54D291EF7E100F496A8 /* EditListSearchFollowingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListSearchFollowingViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D6F6A54F291F058600F496A8 /* CreateListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateListService.swift; sourceTree = "<group>"; };
|
||||||
|
D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = "<group>"; };
|
||||||
|
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteListService.swift; sourceTree = "<group>"; };
|
||||||
|
D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteAccountView.swift; sourceTree = "<group>"; };
|
||||||
|
D6F6A5582920676800F496A8 /* ComposeToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbar.swift; sourceTree = "<group>"; };
|
||||||
D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = "<group>"; };
|
D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = "<group>"; };
|
||||||
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = "<group>"; };
|
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
@ -687,6 +701,7 @@
|
||||||
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
|
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
|
||||||
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
|
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
|
||||||
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
|
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
|
||||||
|
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -736,15 +751,6 @@
|
||||||
path = "Hashtag Cell";
|
path = "Hashtag Cell";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
D61959D0241E842400A37B8E /* Draft Cell */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
D627FF7C217E958900CC0648 /* DraftTableViewCell.xib */,
|
|
||||||
D627FF7E217E95E000CC0648 /* DraftTableViewCell.swift */,
|
|
||||||
);
|
|
||||||
path = "Draft Cell";
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
D61959D2241E846D00A37B8E /* Models */ = {
|
D61959D2241E846D00A37B8E /* Models */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -839,19 +845,12 @@
|
||||||
children = (
|
children = (
|
||||||
D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */,
|
D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */,
|
||||||
D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */,
|
D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */,
|
||||||
|
D6F6A54B291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift */,
|
||||||
|
D6F6A54D291EF7E100F496A8 /* EditListSearchFollowingViewController.swift */,
|
||||||
);
|
);
|
||||||
path = Lists;
|
path = Lists;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
D627FF77217E94F200CC0648 /* Drafts */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
D627FF78217E950100CC0648 /* DraftsTableViewController.xib */,
|
|
||||||
D627FF7A217E951500CC0648 /* DraftsTableViewController.swift */,
|
|
||||||
);
|
|
||||||
path = Drafts;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
D62D241E217AA46B005076CC /* Shortcuts */ = {
|
D62D241E217AA46B005076CC /* Shortcuts */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -899,12 +898,12 @@
|
||||||
D641C787213DD862004B4513 /* Compose */,
|
D641C787213DD862004B4513 /* Compose */,
|
||||||
D641C785213DD83B004B4513 /* Conversation */,
|
D641C785213DD83B004B4513 /* Conversation */,
|
||||||
D6F2E960249E772F005846BB /* Crash Reporter */,
|
D6F2E960249E772F005846BB /* Crash Reporter */,
|
||||||
D627FF77217E94F200CC0648 /* Drafts */,
|
|
||||||
D627943C23A5635D00D38C68 /* Explore */,
|
D627943C23A5635D00D38C68 /* Explore */,
|
||||||
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
|
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
|
||||||
D641C788213DD86D004B4513 /* Large Image */,
|
D641C788213DD86D004B4513 /* Large Image */,
|
||||||
D627944B23A9A02400D38C68 /* Lists */,
|
D627944B23A9A02400D38C68 /* Lists */,
|
||||||
D641C782213DD7F0004B4513 /* Main */,
|
D641C782213DD7F0004B4513 /* Main */,
|
||||||
|
D6F6A555291F4F0C00F496A8 /* Mute */,
|
||||||
D641C786213DD852004B4513 /* Notifications */,
|
D641C786213DD852004B4513 /* Notifications */,
|
||||||
D641C783213DD7FE004B4513 /* Onboarding */,
|
D641C783213DD7FE004B4513 /* Onboarding */,
|
||||||
D641C789213DD87E004B4513 /* Preferences */,
|
D641C789213DD87E004B4513 /* Preferences */,
|
||||||
|
@ -933,6 +932,7 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */,
|
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */,
|
||||||
|
D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */,
|
||||||
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */,
|
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */,
|
||||||
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */,
|
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */,
|
||||||
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */,
|
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */,
|
||||||
|
@ -987,7 +987,6 @@
|
||||||
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */,
|
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */,
|
||||||
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */,
|
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */,
|
||||||
D622759F24F1677200B82A16 /* ComposeHostingController.swift */,
|
D622759F24F1677200B82A16 /* ComposeHostingController.swift */,
|
||||||
D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */,
|
|
||||||
D677284724ECBCB100C732D3 /* ComposeView.swift */,
|
D677284724ECBCB100C732D3 /* ComposeView.swift */,
|
||||||
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */,
|
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */,
|
||||||
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */,
|
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */,
|
||||||
|
@ -1003,6 +1002,8 @@
|
||||||
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
|
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
|
||||||
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */,
|
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */,
|
||||||
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */,
|
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */,
|
||||||
|
D6F6A5582920676800F496A8 /* ComposeToolbar.swift */,
|
||||||
|
D6BEA248291C6118002F4D01 /* DraftsView.swift */,
|
||||||
);
|
);
|
||||||
path = Compose;
|
path = Compose;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1276,6 +1277,7 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
|
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
|
||||||
|
D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */,
|
||||||
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
|
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
|
||||||
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
|
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
|
||||||
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
|
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
|
||||||
|
@ -1289,6 +1291,7 @@
|
||||||
D620483323D3801D008A63EF /* LinkTextView.swift */,
|
D620483323D3801D008A63EF /* LinkTextView.swift */,
|
||||||
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */,
|
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */,
|
||||||
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */,
|
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */,
|
||||||
|
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */,
|
||||||
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
|
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
|
||||||
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
|
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
|
||||||
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
|
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
|
||||||
|
@ -1303,7 +1306,6 @@
|
||||||
D626494023C122C800612E6E /* Asset Picker */,
|
D626494023C122C800612E6E /* Asset Picker */,
|
||||||
D6C7D27B22B6EBE200071952 /* Attachments */,
|
D6C7D27B22B6EBE200071952 /* Attachments */,
|
||||||
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */,
|
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */,
|
||||||
D61959D0241E842400A37B8E /* Draft Cell */,
|
|
||||||
D611C2CC232DC5FC00C86A49 /* Hashtag Cell */,
|
D611C2CC232DC5FC00C86A49 /* Hashtag Cell */,
|
||||||
D61AC1DA232EA43100C54D2D /* Instance Cell */,
|
D61AC1DA232EA43100C54D2D /* Instance Cell */,
|
||||||
D641C78C213DD937004B4513 /* Notifications */,
|
D641C78C213DD937004B4513 /* Notifications */,
|
||||||
|
@ -1359,6 +1361,7 @@
|
||||||
children = (
|
children = (
|
||||||
D63CC703290EC472000E19DE /* Dist.xcconfig */,
|
D63CC703290EC472000E19DE /* Dist.xcconfig */,
|
||||||
D674A50727F910F300BA03AC /* Pachyderm */,
|
D674A50727F910F300BA03AC /* Pachyderm */,
|
||||||
|
D6BEA243291A0C83002F4D01 /* Duckable */,
|
||||||
D6D4DDCE212518A000E1C4BB /* Tusker */,
|
D6D4DDCE212518A000E1C4BB /* Tusker */,
|
||||||
D6D4DDE3212518A200E1C4BB /* TuskerTests */,
|
D6D4DDE3212518A200E1C4BB /* TuskerTests */,
|
||||||
D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
|
D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
|
||||||
|
@ -1486,6 +1489,14 @@
|
||||||
path = "Crash Reporter";
|
path = "Crash Reporter";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D6F6A555291F4F0C00F496A8 /* Mute */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */,
|
||||||
|
);
|
||||||
|
path = Mute;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
D6F953F121251A2F00CF0F2B /* API */ = {
|
D6F953F121251A2F00CF0F2B /* API */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -1494,6 +1505,9 @@
|
||||||
D6E9CDA7281A427800BBC98E /* PostService.swift */,
|
D6E9CDA7281A427800BBC98E /* PostService.swift */,
|
||||||
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */,
|
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */,
|
||||||
D621733228F1D5ED004C7DB1 /* ReblogService.swift */,
|
D621733228F1D5ED004C7DB1 /* ReblogService.swift */,
|
||||||
|
D6F6A54F291F058600F496A8 /* CreateListService.swift */,
|
||||||
|
D6F6A551291F098700F496A8 /* RenameListService.swift */,
|
||||||
|
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */,
|
||||||
);
|
);
|
||||||
path = API;
|
path = API;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1525,6 +1539,7 @@
|
||||||
D674A50827F9128D00BA03AC /* Pachyderm */,
|
D674A50827F9128D00BA03AC /* Pachyderm */,
|
||||||
D6552366289870790048A653 /* ScreenCorners */,
|
D6552366289870790048A653 /* ScreenCorners */,
|
||||||
D63CC701290EC0B8000E19DE /* Sentry */,
|
D63CC701290EC0B8000E19DE /* Sentry */,
|
||||||
|
D6BEA244291A0EDE002F4D01 /* Duckable */,
|
||||||
);
|
);
|
||||||
productName = Tusker;
|
productName = Tusker;
|
||||||
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
|
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
|
||||||
|
@ -1659,7 +1674,6 @@
|
||||||
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */,
|
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */,
|
||||||
D626493923C0FD0000612E6E /* AllPhotosTableViewCell.xib in Resources */,
|
D626493923C0FD0000612E6E /* AllPhotosTableViewCell.xib in Resources */,
|
||||||
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */,
|
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */,
|
||||||
D627FF7D217E958900CC0648 /* DraftTableViewCell.xib in Resources */,
|
|
||||||
D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */,
|
D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */,
|
||||||
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */,
|
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */,
|
||||||
D6C82B5725C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib in Resources */,
|
D6C82B5725C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib in Resources */,
|
||||||
|
@ -1671,7 +1685,6 @@
|
||||||
D663625D2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib in Resources */,
|
D663625D2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib in Resources */,
|
||||||
D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */,
|
D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */,
|
||||||
D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */,
|
D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */,
|
||||||
D627FF79217E950100CC0648 /* DraftsTableViewController.xib in Resources */,
|
|
||||||
D626493D23C1000300612E6E /* AlbumTableViewCell.xib in Resources */,
|
D626493D23C1000300612E6E /* AlbumTableViewCell.xib in Resources */,
|
||||||
D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */,
|
D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */,
|
||||||
D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */,
|
D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */,
|
||||||
|
@ -1764,6 +1777,7 @@
|
||||||
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */,
|
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */,
|
||||||
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
|
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
|
||||||
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
|
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
|
||||||
|
D6F6A54C291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift in Sources */,
|
||||||
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */,
|
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */,
|
||||||
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
|
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
|
||||||
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
|
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
|
||||||
|
@ -1804,6 +1818,7 @@
|
||||||
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
|
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
|
||||||
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */,
|
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */,
|
||||||
D627943723A552C200D38C68 /* BookmarkStatusActivity.swift in Sources */,
|
D627943723A552C200D38C68 /* BookmarkStatusActivity.swift in Sources */,
|
||||||
|
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
|
||||||
D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */,
|
D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */,
|
||||||
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
|
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
|
||||||
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
|
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
|
||||||
|
@ -1850,9 +1865,10 @@
|
||||||
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
||||||
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
|
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
|
||||||
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
|
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
|
||||||
D627FF7B217E951500CC0648 /* DraftsTableViewController.swift in Sources */,
|
D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */,
|
||||||
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */,
|
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */,
|
||||||
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
|
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
|
||||||
|
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
|
||||||
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
|
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
|
||||||
D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */,
|
D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */,
|
||||||
D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */,
|
D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */,
|
||||||
|
@ -1863,7 +1879,6 @@
|
||||||
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
|
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
|
||||||
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */,
|
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */,
|
||||||
D61DC84628F498F200B82C6E /* Logging.swift in Sources */,
|
D61DC84628F498F200B82C6E /* Logging.swift in Sources */,
|
||||||
D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */,
|
|
||||||
D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */,
|
D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */,
|
||||||
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */,
|
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */,
|
||||||
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */,
|
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */,
|
||||||
|
@ -1873,7 +1888,6 @@
|
||||||
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
|
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
|
||||||
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
|
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
|
||||||
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
|
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
|
||||||
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */,
|
|
||||||
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */,
|
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */,
|
||||||
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
|
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
|
||||||
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
|
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
|
||||||
|
@ -1888,6 +1902,7 @@
|
||||||
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
|
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
|
||||||
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
|
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
|
||||||
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
|
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
|
||||||
|
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */,
|
||||||
D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */,
|
D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */,
|
||||||
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
|
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
|
||||||
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */,
|
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */,
|
||||||
|
@ -1921,6 +1936,8 @@
|
||||||
D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */,
|
D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */,
|
||||||
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
|
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
|
||||||
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
|
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
|
||||||
|
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */,
|
||||||
|
D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */,
|
||||||
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
|
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
|
||||||
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */,
|
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */,
|
||||||
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
|
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
|
||||||
|
@ -1978,6 +1995,7 @@
|
||||||
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
|
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
|
||||||
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
|
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
|
||||||
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
||||||
|
D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */,
|
||||||
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
|
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
|
||||||
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */,
|
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */,
|
||||||
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */,
|
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */,
|
||||||
|
@ -1986,13 +2004,16 @@
|
||||||
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */,
|
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */,
|
||||||
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
|
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
|
||||||
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
|
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
|
||||||
|
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */,
|
||||||
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
|
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
|
||||||
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
|
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
|
||||||
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
|
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
|
||||||
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
|
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
|
||||||
|
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */,
|
||||||
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
|
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
|
||||||
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
|
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
|
||||||
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
|
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
|
||||||
|
D6F6A54E291EF7E100F496A8 /* EditListSearchFollowingViewController.swift in Sources */,
|
||||||
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
|
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
|
||||||
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
|
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
|
||||||
D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */,
|
D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */,
|
||||||
|
@ -2166,7 +2187,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 44;
|
CURRENT_PROJECT_VERSION = 45;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2234,7 +2255,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 44;
|
CURRENT_PROJECT_VERSION = 45;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
|
@ -2384,7 +2405,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 44;
|
CURRENT_PROJECT_VERSION = 45;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2413,7 +2434,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 44;
|
CURRENT_PROJECT_VERSION = 45;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2523,7 +2544,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 44;
|
CURRENT_PROJECT_VERSION = 45;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
|
@ -2550,7 +2571,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 44;
|
CURRENT_PROJECT_VERSION = 45;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
|
@ -2689,6 +2710,10 @@
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = Pachyderm;
|
productName = Pachyderm;
|
||||||
};
|
};
|
||||||
|
D6BEA244291A0EDE002F4D01 /* Duckable */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Duckable;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
|
||||||
/* Begin XCVersionGroup section */
|
/* Begin XCVersionGroup section */
|
||||||
|
|
|
@ -0,0 +1,70 @@
|
||||||
|
//
|
||||||
|
// CreateListService.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/11/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class CreateListService {
|
||||||
|
private let mastodonController: MastodonController
|
||||||
|
private let present: (UIViewController) -> Void
|
||||||
|
private let didCreateList: (@MainActor (List) -> Void)?
|
||||||
|
|
||||||
|
private var createAction: UIAlertAction?
|
||||||
|
|
||||||
|
init(mastodonController: MastodonController, present: @escaping (UIViewController) -> Void, didCreateList: (@MainActor (List) -> Void)?) {
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
self.present = present
|
||||||
|
self.didCreateList = didCreateList
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() {
|
||||||
|
let alert = UIAlertController(title: NSLocalizedString("New List", comment: "new list alert title"), message: NSLocalizedString("Choose a title for your new list", comment: "new list alert message"), preferredStyle: .alert)
|
||||||
|
alert.addTextField { textField in
|
||||||
|
textField.addTarget(self, action: #selector(self.alertTextFieldValueChanged), for: .editingChanged)
|
||||||
|
}
|
||||||
|
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "new list alert cancel button"), style: .cancel, handler: nil))
|
||||||
|
createAction = UIAlertAction(title: NSLocalizedString("Create List", comment: "new list create button"), style: .default, handler: { (_) in
|
||||||
|
let textField = alert.textFields!.first!
|
||||||
|
let title = textField.text ?? ""
|
||||||
|
Task {
|
||||||
|
await self.createList(with: title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
createAction!.isEnabled = false
|
||||||
|
alert.addAction(createAction!)
|
||||||
|
present(alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func alertTextFieldValueChanged(_ textField: UITextField) {
|
||||||
|
createAction?.isEnabled = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createList(with title: String) async {
|
||||||
|
do {
|
||||||
|
let request = Client.createList(title: title)
|
||||||
|
let (list, _) = try await mastodonController.run(request)
|
||||||
|
NotificationCenter.default.post(name: .listsChanged, object: nil)
|
||||||
|
self.didCreateList?(list)
|
||||||
|
} catch {
|
||||||
|
let alert = UIAlertController(title: "Error Creating List", message: error.localizedDescription, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "Retry", style: .default, handler: { _ in
|
||||||
|
Task {
|
||||||
|
await self.createList(with: title)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
present(alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Foundation.Notification.Name {
|
||||||
|
static let listsChanged = Notification.Name("listsChanged")
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
//
|
||||||
|
// DeleteListService.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/11/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class DeleteListService {
|
||||||
|
private let list: List
|
||||||
|
private let mastodonController: MastodonController
|
||||||
|
private let present: (UIViewController) -> Void
|
||||||
|
|
||||||
|
init(list: List, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) {
|
||||||
|
self.list = list
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
self.present = present
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func run() async -> Bool {
|
||||||
|
if await presentConfirmationAlert() {
|
||||||
|
await deleteList()
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentConfirmationAlert() async -> Bool {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
let titleFormat = NSLocalizedString("Are you sure you want to delete the '%@' list?", comment: "delete list alert title")
|
||||||
|
let title = String(format: titleFormat, list.title)
|
||||||
|
let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "delete list alert cancel button"), style: .cancel, handler: { (_) in
|
||||||
|
continuation.resume(returning: false)
|
||||||
|
}))
|
||||||
|
alert.addAction(UIAlertAction(title: NSLocalizedString("Delete List", comment: "delete list alert confirm button"), style: .destructive, handler: { (_) in
|
||||||
|
continuation.resume(returning: true)
|
||||||
|
}))
|
||||||
|
present(alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteList() async {
|
||||||
|
do {
|
||||||
|
let request = List.delete(list)
|
||||||
|
_ = try await mastodonController.run(request)
|
||||||
|
NotificationCenter.default.post(name: .listsChanged, object: nil)
|
||||||
|
} catch {
|
||||||
|
let alert = UIAlertController(title: "Error Deleting List", message: error.localizedDescription, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "Retry", style: .default, handler: { _ in
|
||||||
|
Task {
|
||||||
|
await self.deleteList()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
present(alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,11 +10,12 @@ import Foundation
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
struct InstanceFeatures {
|
struct InstanceFeatures {
|
||||||
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; Pleroma (.*)\\)")
|
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive)
|
||||||
|
|
||||||
private(set) var instanceType = InstanceType.mastodon
|
private(set) var instanceType = InstanceType.mastodon
|
||||||
private(set) var version: Version?
|
private(set) var version: Version?
|
||||||
private(set) var pleromaVersion: Version?
|
private(set) var pleromaVersion: Version?
|
||||||
|
private(set) var hometownVersion: Version?
|
||||||
private(set) var maxStatusChars = 500
|
private(set) var maxStatusChars = 500
|
||||||
|
|
||||||
var localOnlyPosts: Bool {
|
var localOnlyPosts: Bool {
|
||||||
|
@ -30,7 +31,7 @@ struct InstanceFeatures {
|
||||||
}
|
}
|
||||||
|
|
||||||
var boostToOriginalAudience: Bool {
|
var boostToOriginalAudience: Bool {
|
||||||
instanceType == .pleroma || instanceType == .mastodon
|
instanceType == .pleroma || instanceType.isMastodon
|
||||||
}
|
}
|
||||||
|
|
||||||
var profilePinnedStatuses: Bool {
|
var profilePinnedStatuses: Bool {
|
||||||
|
@ -38,16 +39,16 @@ struct InstanceFeatures {
|
||||||
}
|
}
|
||||||
|
|
||||||
var trends: Bool {
|
var trends: Bool {
|
||||||
instanceType == .mastodon
|
instanceType.isMastodon
|
||||||
}
|
}
|
||||||
|
|
||||||
var trendingStatusesAndLinks: Bool {
|
var trendingStatusesAndLinks: Bool {
|
||||||
instanceType == .mastodon && hasVersion(3, 5, 0)
|
instanceType.isMastodon && hasVersion(3, 5, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
var reblogVisibility: Bool {
|
var reblogVisibility: Bool {
|
||||||
(instanceType == .mastodon && hasVersion(2, 8, 0))
|
(instanceType.isMastodon && hasVersion(2, 8, 0))
|
||||||
|| (instanceType == .pleroma && hasVersion(2, 0, 0))
|
|| (instanceType == .pleroma && hasPleromaVersion(2, 0, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
var probablySupportsMarkdown: Bool {
|
var probablySupportsMarkdown: Bool {
|
||||||
|
@ -55,24 +56,31 @@ struct InstanceFeatures {
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
|
mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
|
||||||
|
var version: Version?
|
||||||
|
|
||||||
let ver = instance.version.lowercased()
|
let ver = instance.version.lowercased()
|
||||||
if ver.contains("glitch") {
|
if ver.contains("glitch") {
|
||||||
instanceType = .glitch
|
instanceType = .glitch
|
||||||
} else if nodeInfo?.software.name == "hometown" {
|
} else if nodeInfo?.software.name == "hometown" {
|
||||||
instanceType = .hometown
|
instanceType = .hometown
|
||||||
|
// like "1.0.6+3.5.2"
|
||||||
|
let parts = ver.split(separator: "+")
|
||||||
|
if parts.count == 2 {
|
||||||
|
version = Version(string: String(parts[1]))
|
||||||
|
hometownVersion = Version(string: String(parts[0]))
|
||||||
|
}
|
||||||
} else if ver.contains("pleroma") {
|
} else if ver.contains("pleroma") {
|
||||||
instanceType = .pleroma
|
instanceType = .pleroma
|
||||||
|
if let match = InstanceFeatures.pleromaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) {
|
||||||
|
pleromaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 1)))
|
||||||
|
}
|
||||||
} else if ver.contains("pixelfed") {
|
} else if ver.contains("pixelfed") {
|
||||||
instanceType = .pixelfed
|
instanceType = .pixelfed
|
||||||
} else {
|
} else {
|
||||||
instanceType = .mastodon
|
instanceType = .mastodon
|
||||||
}
|
}
|
||||||
|
|
||||||
version = Version(string: ver)
|
self.version = version ?? Version(string: ver)
|
||||||
|
|
||||||
if let match = InstanceFeatures.pleromaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) {
|
|
||||||
pleromaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 1)))
|
|
||||||
}
|
|
||||||
|
|
||||||
maxStatusChars = instance.maxStatusCharacters ?? 500
|
maxStatusChars = instance.maxStatusCharacters ?? 500
|
||||||
}
|
}
|
||||||
|
@ -84,6 +92,14 @@ struct InstanceFeatures {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
||||||
|
if let pleromaVersion {
|
||||||
|
return pleromaVersion >= Version(major, minor, patch)
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InstanceFeatures {
|
extension InstanceFeatures {
|
||||||
|
|
|
@ -50,7 +50,7 @@ class ReblogService {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
image = nil
|
image = nil
|
||||||
reblogVisibilityActions = []
|
reblogVisibilityActions = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let preview = ConfirmReblogStatusPreviewView(status: status)
|
let preview = ConfirmReblogStatusPreviewView(status: status)
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
//
|
||||||
|
// RenameListService.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/11/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class RenameListService {
|
||||||
|
private let list: List
|
||||||
|
private let mastodonController: MastodonController
|
||||||
|
private let present: (UIViewController) -> Void
|
||||||
|
|
||||||
|
private var renameAction: UIAlertAction?
|
||||||
|
|
||||||
|
init(list: List, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) {
|
||||||
|
self.list = list
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
self.present = present
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() {
|
||||||
|
let alert = UIAlertController(title: NSLocalizedString("Rename List", comment: "rename list alert title"), message: nil, preferredStyle: .alert)
|
||||||
|
alert.addTextField { (textField) in
|
||||||
|
textField.text = self.list.title
|
||||||
|
textField.addTarget(self, action: #selector(self.alertTextFieldValueChanged), for: .editingChanged)
|
||||||
|
}
|
||||||
|
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "rename list alert cancel button"), style: .cancel, handler: nil))
|
||||||
|
renameAction = UIAlertAction(title: NSLocalizedString("Rename", comment: "renaem list alert rename button"), style: .default, handler: { (_) in
|
||||||
|
let textField = alert.textFields!.first!
|
||||||
|
let title = textField.text ?? ""
|
||||||
|
Task {
|
||||||
|
await self.updateList(with: title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
alert.addAction(renameAction!)
|
||||||
|
present(alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func alertTextFieldValueChanged(_ textField: UITextField) {
|
||||||
|
renameAction?.isEnabled = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateList(with title: String) async {
|
||||||
|
do {
|
||||||
|
let req = List.update(list, title: title)
|
||||||
|
let (list, _) = try await mastodonController.run(req)
|
||||||
|
NotificationCenter.default.post(name: .listRenamed, object: list.id, userInfo: ["list": list])
|
||||||
|
} catch {
|
||||||
|
let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "Retry", style: .default, handler: { _ in
|
||||||
|
Task {
|
||||||
|
await self.updateList(with: title)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
present(alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Foundation.Notification.Name {
|
||||||
|
static let listRenamed = Notification.Name("listRenamed")
|
||||||
|
}
|
|
@ -81,10 +81,7 @@ class ImageCache {
|
||||||
guard !ImageCache.disableCaching else { return }
|
guard !ImageCache.disableCaching else { return }
|
||||||
|
|
||||||
if !((try? cache.has(url.absoluteString)) ?? false) {
|
if !((try? cache.has(url.absoluteString)) ?? false) {
|
||||||
let task = dataTask(url: url) { data, image in
|
let task = dataTask(url: url, completion: nil)
|
||||||
guard let data else { return }
|
|
||||||
try? self.cache.set(url.absoluteString, data: data, image: image)
|
|
||||||
}
|
|
||||||
task.resume()
|
task.resume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -95,7 +92,9 @@ class ImageCache {
|
||||||
let data else {
|
let data else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
completion?(data, UIImage(data: data))
|
let image = UIImage(data: data)
|
||||||
|
try? self.cache.set(url.absoluteString, data: data, image: image)
|
||||||
|
completion?(data, image)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -221,10 +221,11 @@ class MastodonCachePersistentStore: NSPersistentContainer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addAll(accounts: [Account], completion: (() -> Void)? = nil) {
|
func addAll(accounts: [Account], in context: NSManagedObjectContext? = nil, completion: (() -> Void)? = nil) {
|
||||||
backgroundContext.perform {
|
let context = context ?? backgroundContext
|
||||||
accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
|
context.perform {
|
||||||
self.save(context: self.backgroundContext)
|
accounts.forEach { self.upsert(account: $0, in: context) }
|
||||||
|
self.save(context: context)
|
||||||
completion?()
|
completion?()
|
||||||
accounts.forEach { self.accountSubject.send($0.id) }
|
accounts.forEach { self.accountSubject.send($0.id) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="20086" systemVersion="21E230" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21512" systemVersion="22A380" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||||
<entity name="Account" representedClassName="AccountMO" syncable="YES">
|
<entity name="Account" representedClassName="AccountMO" syncable="YES">
|
||||||
<attribute name="acct" attributeType="String"/>
|
<attribute name="acct" attributeType="String"/>
|
||||||
<attribute name="avatar" optional="YES" attributeType="URI"/>
|
<attribute name="avatar" optional="YES" attributeType="URI"/>
|
||||||
|
@ -21,6 +21,7 @@
|
||||||
<attribute name="username" attributeType="String"/>
|
<attribute name="username" attributeType="String"/>
|
||||||
<relationship name="movedTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
|
<relationship name="movedTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
|
||||||
<relationship name="relationship" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Relationship" inverseName="account" inverseEntity="Relationship"/>
|
<relationship name="relationship" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Relationship" inverseName="account" inverseEntity="Relationship"/>
|
||||||
|
<relationship name="statuses" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Status" inverseName="account" inverseEntity="Status"/>
|
||||||
<uniquenessConstraints>
|
<uniquenessConstraints>
|
||||||
<uniquenessConstraint>
|
<uniquenessConstraint>
|
||||||
<constraint value="id"/>
|
<constraint value="id"/>
|
||||||
|
@ -84,7 +85,7 @@
|
||||||
<attribute name="uri" attributeType="String"/>
|
<attribute name="uri" attributeType="String"/>
|
||||||
<attribute name="url" optional="YES" attributeType="URI"/>
|
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||||
<attribute name="visibilityString" attributeType="String"/>
|
<attribute name="visibilityString" attributeType="String"/>
|
||||||
<relationship name="account" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
|
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/>
|
||||||
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
|
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
|
||||||
<uniquenessConstraints>
|
<uniquenessConstraints>
|
||||||
<uniquenessConstraint>
|
<uniquenessConstraint>
|
||||||
|
@ -92,11 +93,4 @@
|
||||||
</uniquenessConstraint>
|
</uniquenessConstraint>
|
||||||
</uniquenessConstraints>
|
</uniquenessConstraints>
|
||||||
</entity>
|
</entity>
|
||||||
<elements>
|
|
||||||
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="343"/>
|
|
||||||
<element name="Relationship" positionX="63" positionY="135" width="128" height="194"/>
|
|
||||||
<element name="Status" positionX="-63" positionY="-18" width="128" height="449"/>
|
|
||||||
<element name="SavedInstance" positionX="63" positionY="144" width="128" height="44"/>
|
|
||||||
<element name="SavedHashtag" positionX="72" positionY="153" width="128" height="59"/>
|
|
||||||
</elements>
|
|
||||||
</model>
|
</model>
|
|
@ -56,6 +56,7 @@ private let imageType = UTType.image.identifier
|
||||||
private let mp4Type = UTType.mpeg4Movie.identifier
|
private let mp4Type = UTType.mpeg4Movie.identifier
|
||||||
private let quickTimeType = UTType.quickTimeMovie.identifier
|
private let quickTimeType = UTType.quickTimeMovie.identifier
|
||||||
private let dataType = UTType.data.identifier
|
private let dataType = UTType.data.identifier
|
||||||
|
private let gifType = UTType.gif.identifier
|
||||||
|
|
||||||
extension CompositionAttachment: NSItemProviderWriting {
|
extension CompositionAttachment: NSItemProviderWriting {
|
||||||
static var writableTypeIdentifiersForItemProvider: [String] {
|
static var writableTypeIdentifiersForItemProvider: [String] {
|
||||||
|
@ -95,20 +96,22 @@ extension CompositionAttachment: NSItemProviderReading {
|
||||||
[typeIdentifier] + UIImage.readableTypeIdentifiersForItemProvider + [mp4Type, quickTimeType] + NSURL.readableTypeIdentifiersForItemProvider
|
[typeIdentifier] + UIImage.readableTypeIdentifiersForItemProvider + [mp4Type, quickTimeType] + NSURL.readableTypeIdentifiersForItemProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> Self {
|
static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> CompositionAttachment {
|
||||||
if typeIdentifier == CompositionAttachment.typeIdentifier {
|
if typeIdentifier == CompositionAttachment.typeIdentifier {
|
||||||
return try PropertyListDecoder().decode(Self.self, from: data)
|
return try PropertyListDecoder().decode(CompositionAttachment.self, from: data)
|
||||||
|
} else if typeIdentifier == gifType {
|
||||||
|
return CompositionAttachment(data: .gif(data))
|
||||||
} else if UIImage.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let image = try? UIImage.object(withItemProviderData: data, typeIdentifier: typeIdentifier) {
|
} else if UIImage.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let image = try? UIImage.object(withItemProviderData: data, typeIdentifier: typeIdentifier) {
|
||||||
return CompositionAttachment(data: .image(image)) as! Self
|
return CompositionAttachment(data: .image(image))
|
||||||
} else if let type = UTType(typeIdentifier), type == .mpeg4Movie || type == .quickTimeMovie {
|
} else if let type = UTType(typeIdentifier), type == .mpeg4Movie || type == .quickTimeMovie {
|
||||||
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
let temporaryFileName = ProcessInfo().globallyUniqueString
|
let temporaryFileName = ProcessInfo().globallyUniqueString
|
||||||
let fileExt = type.preferredFilenameExtension!
|
let fileExt = type.preferredFilenameExtension!
|
||||||
let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName).appendingPathExtension(fileExt)
|
let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName).appendingPathExtension(fileExt)
|
||||||
try data.write(to: temporaryFileURL)
|
try data.write(to: temporaryFileURL)
|
||||||
return CompositionAttachment(data: .video(temporaryFileURL)) as! Self
|
return CompositionAttachment(data: .video(temporaryFileURL))
|
||||||
} else if NSURL.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let url = try? NSURL.object(withItemProviderData: data, typeIdentifier: typeIdentifier) as URL {
|
} else if NSURL.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let url = try? NSURL.object(withItemProviderData: data, typeIdentifier: typeIdentifier) as URL {
|
||||||
return CompositionAttachment(data: .video(url)) as! Self
|
return CompositionAttachment(data: .video(url))
|
||||||
} else {
|
} else {
|
||||||
throw ItemProviderError.incompatibleTypeIdentifier
|
throw ItemProviderError.incompatibleTypeIdentifier
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ enum CompositionAttachmentData {
|
||||||
case image(UIImage)
|
case image(UIImage)
|
||||||
case video(URL)
|
case video(URL)
|
||||||
case drawing(PKDrawing)
|
case drawing(PKDrawing)
|
||||||
|
case gif(Data)
|
||||||
|
|
||||||
var type: AttachmentType {
|
var type: AttachmentType {
|
||||||
switch self {
|
switch self {
|
||||||
|
@ -27,6 +28,8 @@ enum CompositionAttachmentData {
|
||||||
return .video
|
return .video
|
||||||
case .drawing(_):
|
case .drawing(_):
|
||||||
return .image
|
return .image
|
||||||
|
case .gif(_):
|
||||||
|
return .image
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,13 +72,22 @@ enum CompositionAttachmentData {
|
||||||
}
|
}
|
||||||
|
|
||||||
let utType: UTType
|
let utType: UTType
|
||||||
if dataUTI == "public.heic" {
|
let image = CIImage(data: data)!
|
||||||
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
|
let needsColorSpaceConversion = image.colorSpace?.name != CGColorSpace.sRGB
|
||||||
let image = CIImage(data: data)!
|
|
||||||
|
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
|
||||||
|
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
|
||||||
|
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
|
||||||
|
if needsColorSpaceConversion || dataUTI == "public.heic" {
|
||||||
let context = CIContext()
|
let context = CIContext()
|
||||||
let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!
|
let sRGB = CGColorSpace(name: CGColorSpace.sRGB)!
|
||||||
data = context.jpegRepresentation(of: image, colorSpace: colorSpace, options: [:])!
|
if dataUTI == "public.png" {
|
||||||
utType = .jpeg
|
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: sRGB)!
|
||||||
|
utType = .png
|
||||||
|
} else {
|
||||||
|
data = context.jpegRepresentation(of: image, colorSpace: sRGB)!
|
||||||
|
utType = .jpeg
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
utType = UTType(dataUTI)!
|
utType = UTType(dataUTI)!
|
||||||
}
|
}
|
||||||
|
@ -110,6 +122,8 @@ enum CompositionAttachmentData {
|
||||||
case let .drawing(drawing):
|
case let .drawing(drawing):
|
||||||
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
|
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
|
||||||
completion(.success((image.pngData()!, .png)))
|
completion(.success((image.pngData()!, .png)))
|
||||||
|
case let .gif(data):
|
||||||
|
completion(.success((data, .gif)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -182,6 +196,8 @@ extension CompositionAttachmentData: Codable {
|
||||||
try container.encode("drawing", forKey: .type)
|
try container.encode("drawing", forKey: .type)
|
||||||
let drawingData = drawing.dataRepresentation()
|
let drawingData = drawing.dataRepresentation()
|
||||||
try container.encode(drawingData, forKey: .drawing)
|
try container.encode(drawingData, forKey: .drawing)
|
||||||
|
case .gif(_):
|
||||||
|
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: [], debugDescription: "gif CompositionAttachments cannot be encoded"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -205,7 +221,7 @@ extension CompositionAttachmentData: Codable {
|
||||||
let drawing = try PKDrawing(data: drawingData)
|
let drawing = try PKDrawing(data: drawingData)
|
||||||
self = .drawing(drawing)
|
self = .drawing(drawing)
|
||||||
default:
|
default:
|
||||||
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of 'image' or 'asset'")
|
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of image, asset, or drawing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -107,6 +107,8 @@ extension Draft: Equatable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Draft: Identifiable {}
|
||||||
|
|
||||||
extension Draft {
|
extension Draft {
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class DraftsManager: Codable {
|
class DraftsManager: Codable, ObservableObject {
|
||||||
|
|
||||||
private(set) static var shared: DraftsManager = load()
|
private(set) static var shared: DraftsManager = load()
|
||||||
|
|
||||||
|
@ -48,7 +48,12 @@ class DraftsManager: Codable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var drafts: [UUID: Draft] = [:]
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(drafts, forKey: .drafts)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published private var drafts: [UUID: Draft] = [:]
|
||||||
var sorted: [Draft] {
|
var sorted: [Draft] {
|
||||||
return drafts.values.sorted(by: { $0.lastModified > $1.lastModified })
|
return drafts.values.sorted(by: { $0.lastModified > $1.lastModified })
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
enum StatusFormat: CaseIterable {
|
enum StatusFormat: Int, CaseIterable {
|
||||||
case bold, italics, strikethrough, code
|
case bold, italics, strikethrough, code
|
||||||
|
|
||||||
var insertionResult: FormatInsertionResult? {
|
var insertionResult: FormatInsertionResult? {
|
||||||
|
@ -23,19 +23,17 @@ enum StatusFormat: CaseIterable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var image: UIImage? {
|
var imageName: String? {
|
||||||
let name: String
|
|
||||||
switch self {
|
switch self {
|
||||||
case .italics:
|
case .italics:
|
||||||
name = "italic"
|
return "italic"
|
||||||
case .bold:
|
case .bold:
|
||||||
name = "bold"
|
return "bold"
|
||||||
case .strikethrough:
|
case .strikethrough:
|
||||||
name = "strikethrough"
|
return "strikethrough"
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return UIImage(systemName: name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var title: (String, [NSAttributedString.Key: Any])? {
|
var title: (String, [NSAttributedString.Key: Any])? {
|
||||||
|
|
|
@ -10,6 +10,7 @@ import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import MessageUI
|
import MessageUI
|
||||||
import CoreData
|
import CoreData
|
||||||
|
import Duckable
|
||||||
|
|
||||||
class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
|
class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
|
||||||
|
|
||||||
|
@ -31,10 +32,10 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
|
|
||||||
window = UIWindow(windowScene: windowScene)
|
window = UIWindow(windowScene: windowScene)
|
||||||
|
|
||||||
showAppOrOnboardingUI(session: session)
|
showAppOrOnboardingUI(session: session)
|
||||||
if connectionOptions.urlContexts.count > 0 {
|
if connectionOptions.urlContexts.count > 0 {
|
||||||
self.scene(scene, openURLContexts: connectionOptions.urlContexts)
|
self.scene(scene, openURLContexts: connectionOptions.urlContexts)
|
||||||
}
|
}
|
||||||
|
|
||||||
window!.makeKeyAndVisible()
|
window!.makeKeyAndVisible()
|
||||||
|
|
||||||
|
@ -125,12 +126,19 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest()
|
let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest()
|
||||||
statusReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate)
|
statusReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate)
|
||||||
let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq)
|
let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq)
|
||||||
_ = try? context.execute(deleteStatusReq)
|
deleteStatusReq.resultType = .resultTypeCount
|
||||||
|
if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult {
|
||||||
|
Logging.general.info("Pruned \(res.result as! Int) statuses")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
let accountReq: NSFetchRequest<NSFetchRequestResult> = AccountMO.fetchRequest()
|
let accountReq: NSFetchRequest<NSFetchRequestResult> = AccountMO.fetchRequest()
|
||||||
accountReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate)
|
accountReq.predicate = NSPredicate(format: "((lastFetchedAt = nil) OR (lastFetchedAt < %@)) AND (statuses.@count = 0)", minDate as NSDate)
|
||||||
let deleteAccountReq = NSBatchDeleteRequest(fetchRequest: accountReq)
|
let deleteAccountReq = NSBatchDeleteRequest(fetchRequest: accountReq)
|
||||||
_ = try? context.execute(deleteAccountReq)
|
deleteAccountReq.resultType = .resultTypeCount
|
||||||
|
if let res = try? context.execute(deleteAccountReq) as? NSBatchDeleteResult {
|
||||||
|
Logging.general.info("Pruned \(res.result as! Int) accounts")
|
||||||
|
}
|
||||||
|
|
||||||
try? context.save()
|
try? context.save()
|
||||||
}
|
}
|
||||||
|
@ -198,7 +206,14 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
mastodonController.getOwnAccount()
|
mastodonController.getOwnAccount()
|
||||||
mastodonController.getOwnInstance()
|
mastodonController.getOwnInstance()
|
||||||
|
|
||||||
return MainSplitViewController(mastodonController: mastodonController)
|
let split = MainSplitViewController(mastodonController: mastodonController)
|
||||||
|
if UIDevice.current.userInterfaceIdiom == .phone,
|
||||||
|
#available(iOS 16.0, *) {
|
||||||
|
// TODO: maybe the duckable container should be outside the account switching container
|
||||||
|
return DuckableContainerViewController(child: split)
|
||||||
|
} else {
|
||||||
|
return split
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createOnboardingUI() -> UIViewController {
|
func createOnboardingUI() -> UIViewController {
|
||||||
|
|
|
@ -13,18 +13,14 @@ import AVKit
|
||||||
|
|
||||||
class AssetPreviewViewController: UIViewController {
|
class AssetPreviewViewController: UIViewController {
|
||||||
|
|
||||||
let attachment: CompositionAttachmentData
|
let asset: PHAsset
|
||||||
|
|
||||||
init(attachment: CompositionAttachmentData) {
|
init(asset: PHAsset) {
|
||||||
self.attachment = attachment
|
self.asset = asset
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
convenience init(asset: PHAsset) {
|
|
||||||
self.init(attachment: .asset(asset))
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
@ -34,27 +30,17 @@ class AssetPreviewViewController: UIViewController {
|
||||||
|
|
||||||
view.backgroundColor = .black
|
view.backgroundColor = .black
|
||||||
|
|
||||||
switch attachment {
|
switch asset.mediaType {
|
||||||
case let .image(image):
|
case .image:
|
||||||
showImage(image)
|
if asset.mediaSubtypes.contains(.photoLive) {
|
||||||
case let .video(url):
|
showLivePhoto(asset)
|
||||||
showVideo(asset: AVURLAsset(url: url))
|
} else {
|
||||||
case let .asset(asset):
|
showAssetImage(asset)
|
||||||
switch asset.mediaType {
|
|
||||||
case .image:
|
|
||||||
if asset.mediaSubtypes.contains(.photoLive) {
|
|
||||||
showLivePhoto(asset)
|
|
||||||
} else {
|
|
||||||
showAssetImage(asset)
|
|
||||||
}
|
|
||||||
case .video:
|
|
||||||
showAssetVideo(asset)
|
|
||||||
default:
|
|
||||||
fatalError("asset mediaType must be image or video")
|
|
||||||
}
|
}
|
||||||
case let .drawing(drawing):
|
case .video:
|
||||||
let image = drawing.imageInLightMode(from: drawing.bounds)
|
showAssetVideo(asset)
|
||||||
showImage(image)
|
default:
|
||||||
|
fatalError("asset mediaType must be image or video")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,14 +10,30 @@ import UIKit
|
||||||
import AVKit
|
import AVKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
class GalleryPlayerViewController: AVPlayerViewController {
|
class GalleryPlayerViewController: UIViewController {
|
||||||
|
|
||||||
|
let playerVC = AVPlayerViewController()
|
||||||
|
|
||||||
var attachment: Attachment!
|
var attachment: Attachment!
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
allowsPictureInPicturePlayback = true
|
view.backgroundColor = .black
|
||||||
|
|
||||||
|
playerVC.allowsPictureInPicturePlayback = true
|
||||||
|
|
||||||
|
playerVC.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addChild(playerVC)
|
||||||
|
playerVC.didMove(toParent: self)
|
||||||
|
view.addSubview(playerVC.view)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
playerVC.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
|
playerVC.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
|
playerVC.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
|
playerVC.view.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
|
|
@ -93,8 +93,8 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
|
||||||
return vc
|
return vc
|
||||||
case .video, .audio:
|
case .video, .audio:
|
||||||
let vc = GalleryPlayerViewController()
|
let vc = GalleryPlayerViewController()
|
||||||
vc.player = AVPlayer(url: attachment.url)
|
vc.playerVC.player = AVPlayer(url: attachment.url)
|
||||||
vc.delegate = avPlayerViewControllerDelegate
|
vc.playerVC.delegate = avPlayerViewControllerDelegate
|
||||||
vc.attachment = attachment
|
vc.attachment = attachment
|
||||||
return vc
|
return vc
|
||||||
case .gifv:
|
case .gifv:
|
||||||
|
|
|
@ -13,6 +13,7 @@ struct ComposeAttachmentImage: View {
|
||||||
let attachment: CompositionAttachment
|
let attachment: CompositionAttachment
|
||||||
let fullSize: Bool
|
let fullSize: Bool
|
||||||
|
|
||||||
|
@State private var gifData: Data? = nil
|
||||||
@State private var image: UIImage? = nil
|
@State private var image: UIImage? = nil
|
||||||
@State private var imageContentMode: ContentMode = .fill
|
@State private var imageContentMode: ContentMode = .fill
|
||||||
@State private var imageBackgroundColor: Color = .black
|
@State private var imageBackgroundColor: Color = .black
|
||||||
|
@ -20,7 +21,9 @@ struct ComposeAttachmentImage: View {
|
||||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let image = image {
|
if let gifData {
|
||||||
|
GIFViewWrapper(gifData: gifData)
|
||||||
|
} else if let image {
|
||||||
Image(uiImage: image)
|
Image(uiImage: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: imageContentMode)
|
.aspectRatio(contentMode: imageContentMode)
|
||||||
|
@ -54,9 +57,23 @@ struct ComposeAttachmentImage: View {
|
||||||
// currently only used as thumbnail in ComposeAttachmentRow
|
// currently only used as thumbnail in ComposeAttachmentRow
|
||||||
size = CGSize(width: 80, height: 80)
|
size = CGSize(width: 80, height: 80)
|
||||||
}
|
}
|
||||||
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
|
let isGIF = PHAssetResource.assetResources(for: asset).contains(where: { $0.uniformTypeIdentifier == UTType.gif.identifier })
|
||||||
DispatchQueue.main.async {
|
if isGIF {
|
||||||
self.image = image
|
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
|
||||||
|
if typeIdentifier == UTType.gif.identifier {
|
||||||
|
self.gifData = data
|
||||||
|
} else if let data {
|
||||||
|
let image = UIImage(data: data)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.image = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.image = image
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case let .video(url):
|
case let .video(url):
|
||||||
|
@ -69,10 +86,35 @@ struct ComposeAttachmentImage: View {
|
||||||
image = drawing.imageInLightMode(from: drawing.bounds)
|
image = drawing.imageInLightMode(from: drawing.bounds)
|
||||||
imageContentMode = .fit
|
imageContentMode = .fit
|
||||||
imageBackgroundColor = .white
|
imageBackgroundColor = .white
|
||||||
|
case let .gif(data):
|
||||||
|
self.gifData = data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct GIFViewWrapper: UIViewRepresentable {
|
||||||
|
typealias UIViewType = GIFImageView
|
||||||
|
|
||||||
|
@State private var controller: GIFController
|
||||||
|
|
||||||
|
init(gifData: Data) {
|
||||||
|
self._controller = State(wrappedValue: GIFController(gifData: gifData))
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> GIFImageView {
|
||||||
|
let view = GIFImageView()
|
||||||
|
controller.attach(to: view)
|
||||||
|
controller.startAnimating()
|
||||||
|
view.contentMode = .scaleAspectFit
|
||||||
|
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: GIFImageView, context: Context) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ComposeAttachmentImage_Previews: PreviewProvider {
|
struct ComposeAttachmentImage_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ComposeAttachmentImage(attachment: CompositionAttachment(data: .image(UIImage())), fullSize: false)
|
ComposeAttachmentImage(attachment: CompositionAttachment(data: .image(UIImage())), fullSize: false)
|
||||||
|
|
|
@ -14,7 +14,6 @@ import Vision
|
||||||
struct ComposeAttachmentRow: View {
|
struct ComposeAttachmentRow: View {
|
||||||
@ObservedObject var draft: Draft
|
@ObservedObject var draft: Draft
|
||||||
@ObservedObject var attachment: CompositionAttachment
|
@ObservedObject var attachment: CompositionAttachment
|
||||||
let heightChanged: (CGFloat) -> Void
|
|
||||||
|
|
||||||
@EnvironmentObject var uiState: ComposeUIState
|
@EnvironmentObject var uiState: ComposeUIState
|
||||||
@State private var mode: Mode = .allowEntry
|
@State private var mode: Mode = .allowEntry
|
||||||
|
@ -47,7 +46,6 @@ struct ComposeAttachmentRow: View {
|
||||||
switch mode {
|
switch mode {
|
||||||
case .allowEntry:
|
case .allowEntry:
|
||||||
ComposeTextView(text: $attachment.attachmentDescription, placeholder: Text("Describe for the visually impaired…"), minHeight: 80)
|
ComposeTextView(text: $attachment.attachmentDescription, placeholder: Text("Describe for the visually impaired…"), minHeight: 80)
|
||||||
.heightDidChange(self.heightChanged)
|
|
||||||
.backgroundColor(.clear)
|
.backgroundColor(.clear)
|
||||||
|
|
||||||
case .recognizingText:
|
case .recognizingText:
|
||||||
|
|
|
@ -18,23 +18,17 @@ struct ComposeAttachmentsList: View {
|
||||||
@EnvironmentObject var uiState: ComposeUIState
|
@EnvironmentObject var uiState: ComposeUIState
|
||||||
@State var isShowingAssetPickerPopover = false
|
@State var isShowingAssetPickerPopover = false
|
||||||
@State var isShowingCreateDrawing = false
|
@State var isShowingCreateDrawing = false
|
||||||
@State var rowHeights = [UUID: CGFloat]()
|
|
||||||
|
|
||||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass?
|
@Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
Group {
|
||||||
ForEach(draft.attachments) { (attachment) in
|
ForEach(draft.attachments) { (attachment) in
|
||||||
ComposeAttachmentRow(
|
ComposeAttachmentRow(
|
||||||
draft: draft,
|
draft: draft,
|
||||||
attachment: attachment
|
attachment: attachment
|
||||||
) { (newHeight) in
|
)
|
||||||
// in case height changed callback is called after atachment is removed but before view hierarchy is updated
|
|
||||||
if draft.attachments.contains(where: { $0.id == attachment.id }) {
|
|
||||||
rowHeights[attachment.id] = newHeight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||||
.onDrag { NSItemProvider(object: attachment) }
|
.onDrag { NSItemProvider(object: attachment) }
|
||||||
}
|
}
|
||||||
|
@ -69,12 +63,7 @@ struct ComposeAttachmentsList: View {
|
||||||
.frame(height: cellHeight / 2)
|
.frame(height: cellHeight / 2)
|
||||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||||
}
|
}
|
||||||
.listStyle(PlainListStyle())
|
|
||||||
// todo: scrollDisabled doesn't remove the need for manually calculating the frame height
|
|
||||||
.frame(height: totalListHeight)
|
|
||||||
.scrollDisabledIfAvailable(totalHeight: totalListHeight)
|
|
||||||
.onAppear(perform: self.didAppear)
|
.onAppear(perform: self.didAppear)
|
||||||
.onReceive(draft.$attachments, perform: self.attachmentsChanged)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var addButtonImageName: String {
|
private var addButtonImageName: String {
|
||||||
|
@ -104,13 +93,6 @@ struct ComposeAttachmentsList: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var totalListHeight: CGFloat {
|
|
||||||
let totalRowHeights = rowHeights.values.reduce(0, +)
|
|
||||||
let totalPadding = CGFloat(draft.attachments.count) * cellPadding
|
|
||||||
let addButtonHeight = 3 * (cellHeight / 2 + cellPadding)
|
|
||||||
return totalRowHeights + totalPadding + addButtonHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
private func didAppear() {
|
private func didAppear() {
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
// these appearance proxy hacks are no longer necessary
|
// these appearance proxy hacks are no longer necessary
|
||||||
|
@ -122,17 +104,6 @@ struct ComposeAttachmentsList: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func attachmentsChanged(attachments: [CompositionAttachment]) {
|
|
||||||
var copy = rowHeights
|
|
||||||
for k in copy.keys where !attachments.contains(where: { k == $0.id }) {
|
|
||||||
copy.removeValue(forKey: k)
|
|
||||||
}
|
|
||||||
for attachment in attachments where !copy.keys.contains(attachment.id) {
|
|
||||||
copy[attachment.id] = cellHeight
|
|
||||||
}
|
|
||||||
self.rowHeights = copy
|
|
||||||
}
|
|
||||||
|
|
||||||
private func assetPickerPopover() -> some View {
|
private func assetPickerPopover() -> some View {
|
||||||
ComposeAssetPicker(draft: draft, delegate: uiState.delegate?.assetPickerDelegate)
|
ComposeAssetPicker(draft: draft, delegate: uiState.delegate?.assetPickerDelegate)
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
|
@ -214,16 +185,6 @@ fileprivate extension View {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS, obsoleted: 16.0)
|
|
||||||
@ViewBuilder
|
|
||||||
func scrollDisabledIfAvailable(totalHeight: CGFloat) -> some View {
|
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
self.scrollDisabled(true)
|
|
||||||
} else {
|
|
||||||
self.frame(height: totalHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
|
|
|
@ -1,35 +0,0 @@
|
||||||
//
|
|
||||||
// ComposeContainerView.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 8/24/20.
|
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
struct ComposeContainerView: View {
|
|
||||||
let mastodonController: MastodonController
|
|
||||||
@ObservedObject var uiState: ComposeUIState
|
|
||||||
|
|
||||||
init(
|
|
||||||
mastodonController: MastodonController,
|
|
||||||
uiState: ComposeUIState
|
|
||||||
) {
|
|
||||||
self.mastodonController = mastodonController
|
|
||||||
self.uiState = uiState
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ComposeView(draft: uiState.draft)
|
|
||||||
.environmentObject(mastodonController)
|
|
||||||
.environmentObject(uiState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//struct ComposeContainerView_Previews: PreviewProvider {
|
|
||||||
// static var previews: some View {
|
|
||||||
// ComposeContainerView()
|
|
||||||
// }
|
|
||||||
//}
|
|
|
@ -13,15 +13,19 @@ struct ComposeEmojiTextField: UIViewRepresentable {
|
||||||
|
|
||||||
@EnvironmentObject private var uiState: ComposeUIState
|
@EnvironmentObject private var uiState: ComposeUIState
|
||||||
|
|
||||||
@Binding private var text: String
|
@Binding var text: String
|
||||||
private let placeholder: String
|
let placeholder: String
|
||||||
private var didChange: ((String) -> Void)?
|
let becomeFirstResponder: Binding<Bool>?
|
||||||
private var didEndEditing: (() -> Void)?
|
let focusNextView: Binding<Bool>?
|
||||||
|
private var didChange: ((String) -> Void)? = nil
|
||||||
|
private var didEndEditing: (() -> Void)? = nil
|
||||||
private var backgroundColor: UIColor? = nil
|
private var backgroundColor: UIColor? = nil
|
||||||
|
|
||||||
init(text: Binding<String>, placeholder: String) {
|
init(text: Binding<String>, placeholder: String, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) {
|
||||||
self._text = text
|
self._text = text
|
||||||
self.placeholder = placeholder
|
self.placeholder = placeholder
|
||||||
|
self.becomeFirstResponder = becomeFirstResponder
|
||||||
|
self.focusNextView = focusNextView
|
||||||
self.didChange = nil
|
self.didChange = nil
|
||||||
self.didEndEditing = nil
|
self.didEndEditing = nil
|
||||||
}
|
}
|
||||||
|
@ -52,6 +56,7 @@ struct ComposeEmojiTextField: UIViewRepresentable {
|
||||||
|
|
||||||
view.delegate = context.coordinator
|
view.delegate = context.coordinator
|
||||||
view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged)
|
view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged)
|
||||||
|
view.addTarget(context.coordinator, action: #selector(Coordinator.returnKeyPressed), for: .primaryActionTriggered)
|
||||||
|
|
||||||
// otherwise when the text gets too wide it starts expanding the ComposeView
|
// otherwise when the text gets too wide it starts expanding the ComposeView
|
||||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
@ -71,6 +76,14 @@ struct ComposeEmojiTextField: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
context.coordinator.didChange = didChange
|
context.coordinator.didChange = didChange
|
||||||
context.coordinator.didEndEditing = didEndEditing
|
context.coordinator.didEndEditing = didEndEditing
|
||||||
|
context.coordinator.focusNextView = focusNextView
|
||||||
|
|
||||||
|
if becomeFirstResponder?.wrappedValue == true {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
uiView.becomeFirstResponder()
|
||||||
|
becomeFirstResponder?.wrappedValue = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
|
@ -84,6 +97,7 @@ struct ComposeEmojiTextField: UIViewRepresentable {
|
||||||
unowned var uiState: ComposeUIState!
|
unowned var uiState: ComposeUIState!
|
||||||
var didChange: ((String) -> Void)?
|
var didChange: ((String) -> Void)?
|
||||||
var didEndEditing: (() -> Void)?
|
var didEndEditing: (() -> Void)?
|
||||||
|
var focusNextView: Binding<Bool>?
|
||||||
|
|
||||||
var skipSettingTextOnNextUpdate = false
|
var skipSettingTextOnNextUpdate = false
|
||||||
|
|
||||||
|
@ -96,12 +110,17 @@ struct ComposeEmojiTextField: UIViewRepresentable {
|
||||||
didChange?(text.wrappedValue)
|
didChange?(text.wrappedValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func returnKeyPressed() {
|
||||||
|
focusNextView?.wrappedValue = true
|
||||||
|
}
|
||||||
|
|
||||||
func textFieldDidBeginEditing(_ textField: UITextField) {
|
func textFieldDidBeginEditing(_ textField: UITextField) {
|
||||||
uiState.currentInput = self
|
uiState.currentInput = self
|
||||||
updateAutocompleteState(textField: textField)
|
updateAutocompleteState(textField: textField)
|
||||||
}
|
}
|
||||||
|
|
||||||
func textFieldDidEndEditing(_ textField: UITextField) {
|
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||||
|
uiState.currentInput = nil
|
||||||
updateAutocompleteState(textField: textField)
|
updateAutocompleteState(textField: textField)
|
||||||
didEndEditing?()
|
didEndEditing?()
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,14 +10,16 @@ import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import PencilKit
|
import PencilKit
|
||||||
|
import Duckable
|
||||||
|
|
||||||
protocol ComposeHostingControllerDelegate: AnyObject {
|
protocol ComposeHostingControllerDelegate: AnyObject {
|
||||||
func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool
|
func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
class ComposeHostingController: UIHostingController<ComposeView>, DuckableViewController {
|
||||||
|
|
||||||
weak var delegate: ComposeHostingControllerDelegate?
|
weak var delegate: ComposeHostingControllerDelegate?
|
||||||
|
weak var duckableDelegate: DuckableViewControllerDelegate?
|
||||||
|
|
||||||
let mastodonController: MastodonController
|
let mastodonController: MastodonController
|
||||||
|
|
||||||
|
@ -27,13 +29,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
||||||
|
|
||||||
private var cancellables = [AnyCancellable]()
|
private var cancellables = [AnyCancellable]()
|
||||||
|
|
||||||
private var toolbarHeight: CGFloat = 44
|
|
||||||
|
|
||||||
private var mainToolbar: UIToolbar!
|
|
||||||
private var inputAccessoryToolbar: UIToolbar!
|
|
||||||
|
|
||||||
override var inputAccessoryView: UIView? { inputAccessoryToolbar }
|
|
||||||
|
|
||||||
init(draft: Draft? = nil, mastodonController: MastodonController) {
|
init(draft: Draft? = nil, mastodonController: MastodonController) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
let realDraft = draft ?? Draft(accountID: mastodonController.accountInfo!.id)
|
let realDraft = draft ?? Draft(accountID: mastodonController.accountInfo!.id)
|
||||||
|
@ -41,49 +36,18 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
||||||
|
|
||||||
self.uiState = ComposeUIState(draft: realDraft)
|
self.uiState = ComposeUIState(draft: realDraft)
|
||||||
|
|
||||||
// we need our own environment object wrapper so that we can set the mastodon controller as an
|
let compose = ComposeView(
|
||||||
// environment object and setup the draft change listener while still having a concrete type
|
|
||||||
// to use as the UIHostingController type parameter
|
|
||||||
let container = ComposeContainerView(
|
|
||||||
mastodonController: mastodonController,
|
mastodonController: mastodonController,
|
||||||
uiState: uiState
|
uiState: uiState
|
||||||
)
|
)
|
||||||
super.init(rootView: container)
|
super.init(rootView: compose)
|
||||||
|
|
||||||
self.uiState.delegate = self
|
self.uiState.delegate = self
|
||||||
|
|
||||||
// main toolbar is shown at the bottom of the screen, the input accessory is attached to the keyboard while editing
|
|
||||||
// (except for MainComposeTextView which has its own accessory to add formatting buttons)
|
|
||||||
mainToolbar = UIToolbar()
|
|
||||||
mainToolbar.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
mainToolbar.isAccessibilityElement = true
|
|
||||||
setupToolbarItems(toolbar: mainToolbar, input: nil)
|
|
||||||
inputAccessoryToolbar = UIToolbar()
|
|
||||||
inputAccessoryToolbar.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
inputAccessoryToolbar.isAccessibilityElement = true
|
|
||||||
setupToolbarItems(toolbar: inputAccessoryToolbar, input: nil)
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(composeKeyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(composeKeyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(composeKeyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil)
|
|
||||||
|
|
||||||
// add the height of the toolbar itself to the bottom of the safe area so content inside SwiftUI ScrollView doesn't underflow it
|
|
||||||
updateAdditionalSafeAreaInsets()
|
|
||||||
|
|
||||||
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
|
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
|
||||||
|
|
||||||
userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id)
|
userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id)
|
||||||
|
|
||||||
self.uiState.$draft
|
|
||||||
.flatMap(\.$visibility)
|
|
||||||
.sink(receiveValue: self.visibilityChanged)
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
self.uiState.$draft
|
|
||||||
.flatMap(\.$localOnly)
|
|
||||||
.sink(receiveValue: self.localOnlyChanged)
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
self.uiState.$draft
|
self.uiState.$draft
|
||||||
.flatMap(\.objectWillChange)
|
.flatMap(\.objectWillChange)
|
||||||
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility))
|
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility))
|
||||||
|
@ -91,32 +55,12 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
||||||
DraftsManager.save()
|
DraftsManager.save()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
self.uiState.$currentInput
|
|
||||||
.sink { [unowned self] in
|
|
||||||
self.setupToolbarItems(toolbar: self.inputAccessoryToolbar, input: $0)
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder aDecoder: NSCoder) {
|
required init?(coder aDecoder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
override func didMove(toParent parent: UIViewController?) {
|
|
||||||
super.didMove(toParent: parent)
|
|
||||||
|
|
||||||
if let parent = parent {
|
|
||||||
parent.view.addSubview(mainToolbar)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
mainToolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
mainToolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
// use the top anchor of the toolbar so our additionalSafeAreaInsets (which has the bottom as the toolbar height) don't affect it
|
|
||||||
mainToolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
|
||||||
|
@ -126,159 +70,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
||||||
DraftsManager.save()
|
DraftsManager.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupToolbarItems(toolbar: UIToolbar, input: ComposeInput?) {
|
|
||||||
var items: [UIBarButtonItem] = []
|
|
||||||
|
|
||||||
items.append(UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed)))
|
|
||||||
|
|
||||||
let visibilityItem = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil)
|
|
||||||
visibilityItem.tag = ViewTags.composeVisibilityBarButton
|
|
||||||
items.append(visibilityItem)
|
|
||||||
|
|
||||||
if mastodonController.instanceFeatures.localOnlyPosts {
|
|
||||||
let item = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil)
|
|
||||||
item.tag = ViewTags.composeLocalOnlyBarButton
|
|
||||||
items.append(item)
|
|
||||||
localOnlyChanged(draft.localOnly)
|
|
||||||
}
|
|
||||||
|
|
||||||
if input?.toolbarElements.contains(.emojiPicker) == true {
|
|
||||||
items.append(UIBarButtonItem(image: UIImage(systemName: "face.smiling"), style: .plain, target: self, action: #selector(emojiPickerButtonPressed)))
|
|
||||||
}
|
|
||||||
|
|
||||||
items.append(UIBarButtonItem(systemItem: .flexibleSpace))
|
|
||||||
|
|
||||||
if input?.toolbarElements.contains(.formattingButtons) == true,
|
|
||||||
Preferences.shared.statusContentType != .plain {
|
|
||||||
|
|
||||||
for (idx, format) in StatusFormat.allCases.enumerated() {
|
|
||||||
let item: UIBarButtonItem
|
|
||||||
if let image = format.image {
|
|
||||||
item = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(formatButtonPressed(_:)))
|
|
||||||
} else if let (str, attributes) = format.title {
|
|
||||||
item = UIBarButtonItem(title: str, style: .plain, target: self, action: #selector(formatButtonPressed(_:)))
|
|
||||||
item.setTitleTextAttributes(attributes, for: .normal)
|
|
||||||
item.setTitleTextAttributes(attributes, for: .highlighted)
|
|
||||||
} else {
|
|
||||||
fatalError("StatusFormat must have either image or title")
|
|
||||||
}
|
|
||||||
item.tag = StatusFormat.allCases.firstIndex(of: format)!
|
|
||||||
item.accessibilityLabel = format.accessibilityLabel
|
|
||||||
|
|
||||||
items.append(item)
|
|
||||||
if idx != StatusFormat.allCases.count - 1 {
|
|
||||||
let spacer = UIBarButtonItem(systemItem: .fixedSpace)
|
|
||||||
spacer.width = 8
|
|
||||||
items.append(spacer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
items.append(UIBarButtonItem(systemItem: .flexibleSpace))
|
|
||||||
}
|
|
||||||
|
|
||||||
items.append(UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPresed)))
|
|
||||||
|
|
||||||
toolbar.items = items
|
|
||||||
visibilityChanged(draft.visibility)
|
|
||||||
localOnlyChanged(draft.localOnly)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateAdditionalSafeAreaInsets() {
|
|
||||||
additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: toolbarHeight, right: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func composeKeyboardWillShow(_ notification: Foundation.Notification) {
|
|
||||||
keyboardWillShow(accessoryView: inputAccessoryToolbar, notification: notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
func keyboardWillShow(accessoryView: UIView, notification: Foundation.Notification) {
|
|
||||||
mainToolbar.isHidden = true
|
|
||||||
|
|
||||||
accessoryView.alpha = 1
|
|
||||||
accessoryView.isHidden = false
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func composeKeyboardWillHide(_ notification: Foundation.Notification) {
|
|
||||||
keyboardWillHide(accessoryView: inputAccessoryToolbar, notification: notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
func keyboardWillHide(accessoryView: UIView, notification: Foundation.Notification) {
|
|
||||||
mainToolbar.isHidden = false
|
|
||||||
|
|
||||||
let userInfo = notification.userInfo!
|
|
||||||
let durationObj = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as! NSNumber
|
|
||||||
let duration = TimeInterval(durationObj.doubleValue)
|
|
||||||
let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as! NSNumber
|
|
||||||
let curve = UIView.AnimationCurve(rawValue: curveValue.intValue)!
|
|
||||||
let curveOption: UIView.AnimationOptions
|
|
||||||
switch curve {
|
|
||||||
case .easeInOut:
|
|
||||||
curveOption = .curveEaseInOut
|
|
||||||
case .easeIn:
|
|
||||||
curveOption = .curveEaseIn
|
|
||||||
case .easeOut:
|
|
||||||
curveOption = .curveEaseOut
|
|
||||||
case .linear:
|
|
||||||
curveOption = .curveLinear
|
|
||||||
@unknown default:
|
|
||||||
curveOption = .curveLinear
|
|
||||||
}
|
|
||||||
UIView.animate(withDuration: duration, delay: 0, options: curveOption) {
|
|
||||||
accessoryView.alpha = 0
|
|
||||||
} completion: { (finished) in
|
|
||||||
accessoryView.alpha = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func composeKeyboardDidHide(_ notification: Foundation.Notification) {
|
|
||||||
keyboardDidHide(accessoryView: inputAccessoryToolbar, notification: notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
func keyboardDidHide(accessoryView: UIView, notification: Foundation.Notification) {
|
|
||||||
accessoryView.isHidden = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func visibilityChanged(_ newVisibility: Status.Visibility) {
|
|
||||||
for toolbar in [mainToolbar, inputAccessoryToolbar] {
|
|
||||||
guard let item = toolbar?.items?.first(where: { $0.tag == ViewTags.composeVisibilityBarButton }) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
item.image = UIImage(systemName: newVisibility.imageName)
|
|
||||||
item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName)
|
|
||||||
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
|
|
||||||
let state = visibility == newVisibility ? UIMenuElement.State.on : .off
|
|
||||||
return UIAction(title: visibility.displayName, subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName), state: state) { [unowned self] (_) in
|
|
||||||
self.draft.visibility = visibility
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func localOnlyChanged(_ localOnly: Bool) {
|
|
||||||
for toolbar in [mainToolbar, inputAccessoryToolbar] {
|
|
||||||
guard let item = toolbar?.items?.first(where: { $0.tag == ViewTags.composeLocalOnlyBarButton }) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if localOnly {
|
|
||||||
item.image = UIImage(named: "link.broken")
|
|
||||||
item.accessibilityLabel = "Local-only"
|
|
||||||
} else {
|
|
||||||
item.image = UIImage(systemName: "link")
|
|
||||||
item.accessibilityLabel = "Federated"
|
|
||||||
}
|
|
||||||
let instanceSubtitle = "Only \(mastodonController.accountInfo!.instanceURL.host!)"
|
|
||||||
item.menu = UIMenu(children: [
|
|
||||||
UIAction(title: "Local-only", subtitle: instanceSubtitle, image: UIImage(named: "link.broken"), state: localOnly ? .on : .off) { [unowned self] (_) in
|
|
||||||
self.draft.localOnly = true
|
|
||||||
},
|
|
||||||
UIAction(title: "Federated", image: UIImage(systemName: "link"), state: localOnly ? .off : .on) { [unowned self] (_) in
|
|
||||||
self.draft.localOnly = false
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
|
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
|
||||||
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false }
|
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false }
|
||||||
if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||||
|
@ -301,6 +92,18 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Duckable
|
||||||
|
|
||||||
|
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {
|
||||||
|
withAnimation(.linear(duration: duration).delay(delay)) {
|
||||||
|
uiState.isDucking = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func duckableViewControllerDidFinishAnimatingDuck() {
|
||||||
|
uiState.isDucking = false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: Interaction
|
||||||
|
|
||||||
@objc func cwButtonPressed() {
|
@objc func cwButtonPressed() {
|
||||||
|
@ -321,9 +124,7 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func draftsButtonPresed() {
|
@objc func draftsButtonPresed() {
|
||||||
let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!, exclude: draft)
|
uiState.isShowingDraftsList = true
|
||||||
draftsVC.delegate = self
|
|
||||||
present(UINavigationController(rootViewController: draftsVC), animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -335,6 +136,7 @@ extension ComposeHostingController: ComposeUIStateDelegate {
|
||||||
let dismissed = delegate?.dismissCompose(mode: mode) ?? false
|
let dismissed = delegate?.dismissCompose(mode: mode) ?? false
|
||||||
if !dismissed {
|
if !dismissed {
|
||||||
self.dismiss(animated: true)
|
self.dismiss(animated: true)
|
||||||
|
self.duckableDelegate?.duckableViewControllerWillDismiss(animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -362,6 +164,16 @@ extension ComposeHostingController: ComposeUIStateDelegate {
|
||||||
|
|
||||||
present(ComposeDrawingNavigationController(editing: drawing, delegate: self), animated: true)
|
present(ComposeDrawingNavigationController(editing: drawing, delegate: self), animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func selectDraft(_ draft: Draft) {
|
||||||
|
if self.draft.hasContent {
|
||||||
|
DraftsManager.save()
|
||||||
|
} else {
|
||||||
|
DraftsManager.shared.remove(self.draft)
|
||||||
|
}
|
||||||
|
uiState.draft = draft
|
||||||
|
uiState.isShowingDraftsList = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ComposeHostingController: AssetPickerViewControllerDelegate {
|
extension ComposeHostingController: AssetPickerViewControllerDelegate {
|
||||||
|
@ -388,47 +200,17 @@ extension ComposeHostingController: AssetPickerViewControllerDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ComposeHostingController: DraftsTableViewControllerDelegate {
|
// superseded by duckable stuff
|
||||||
func draftSelectionCanceled() {
|
@available(iOS, obsoleted: 16.0)
|
||||||
}
|
|
||||||
|
|
||||||
func shouldSelectDraft(_ draft: Draft, completion: @escaping (Bool) -> Void) {
|
|
||||||
if draft.inReplyToID != self.draft.inReplyToID,
|
|
||||||
self.draft.hasContent {
|
|
||||||
let alertController = UIAlertController(title: "Different Reply", message: "The selected draft is a reply to a different status, do you wish to use it?", preferredStyle: .alert)
|
|
||||||
alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (_) in
|
|
||||||
completion(false)
|
|
||||||
}))
|
|
||||||
alertController.addAction(UIAlertAction(title: "Restore Draft", style: .default, handler: { (_) in
|
|
||||||
completion(true)
|
|
||||||
}))
|
|
||||||
// we can't present the laert ourselves since the compose VC is already presenting the draft selector
|
|
||||||
// but presenting on the presented view controller seems hacky, is there a better way to do this?
|
|
||||||
presentedViewController!.present(alertController, animated: true)
|
|
||||||
} else {
|
|
||||||
completion(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func draftSelected(_ draft: Draft) {
|
|
||||||
if self.draft.hasContent {
|
|
||||||
DraftsManager.save()
|
|
||||||
} else {
|
|
||||||
DraftsManager.shared.remove(self.draft)
|
|
||||||
}
|
|
||||||
|
|
||||||
uiState.draft = draft
|
|
||||||
}
|
|
||||||
|
|
||||||
func draftSelectionCompleted() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ComposeHostingController: UIAdaptivePresentationControllerDelegate {
|
extension ComposeHostingController: UIAdaptivePresentationControllerDelegate {
|
||||||
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
|
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
|
||||||
return Preferences.shared.automaticallySaveDrafts || !draft.hasContent
|
return Preferences.shared.automaticallySaveDrafts || !draft.hasContent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
|
||||||
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: self, for: nil)
|
||||||
|
}
|
||||||
|
|
||||||
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
|
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
|
||||||
uiState.isShowingSaveDraftSheet = true
|
uiState.isShowingSaveDraftSheet = true
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,6 +44,8 @@ struct ComposePollView: View {
|
||||||
.imageScale(.small)
|
.imageScale(.small)
|
||||||
.padding(4)
|
.padding(4)
|
||||||
}
|
}
|
||||||
|
.accessibilityLabel("Remove poll")
|
||||||
|
.buttonStyle(.plain)
|
||||||
.accentColor(buttonForegroundColor)
|
.accentColor(buttonForegroundColor)
|
||||||
.background(Circle().foregroundColor(buttonBackgroundColor))
|
.background(Circle().foregroundColor(buttonBackgroundColor))
|
||||||
.hoverEffect()
|
.hoverEffect()
|
||||||
|
@ -52,31 +54,22 @@ struct ComposePollView: View {
|
||||||
ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in
|
ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in
|
||||||
ComposePollOption(poll: poll, option: e.element, optionIndex: e.offset)
|
ComposePollOption(poll: poll, option: e.element, optionIndex: e.offset)
|
||||||
}
|
}
|
||||||
.transition(.slide)
|
|
||||||
|
|
||||||
Button(action: self.addOption) {
|
Button(action: self.addOption) {
|
||||||
Label("Add Option", systemImage: "plus")
|
Label("Add Option", systemImage: "plus")
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
// use .animation(nil) on pickers so frame doesn't have a size change animation when the text changes
|
MenuPicker(selection: $poll.multiple, options: [
|
||||||
// this is deprecated in iOS 15, but using .animation(nil, value: poll.multiple) does not work (it still animates)
|
.init(value: true, title: "Allow multiple"),
|
||||||
// nor does setting that on the Text rather than the Picker
|
.init(value: false, title: "Single choice"),
|
||||||
Picker(selection: $poll.multiple, label: Text(poll.multiple ? "Allow multiple choice" : "Single choice")) {
|
])
|
||||||
Text("Allow multiple choices").tag(true)
|
|
||||||
Text("Single choice").tag(false)
|
|
||||||
}
|
|
||||||
.animation(nil)
|
|
||||||
.pickerStyle(MenuPickerStyle())
|
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
Picker(selection: $duration, label: Text(verbatim: ComposePollView.formatter.string(from: duration.timeInterval)!)) {
|
MenuPicker(selection: $duration, options: Duration.allCases.map {
|
||||||
ForEach(Duration.allCases, id: \.self) { (duration) in
|
.init(value: $0, title: ComposePollView.formatter.string(from: $0.timeInterval)!)
|
||||||
Text(ComposePollView.formatter.string(from: duration.timeInterval)!).tag(duration)
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(nil)
|
|
||||||
.pickerStyle(MenuPickerStyle())
|
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -110,9 +103,7 @@ struct ComposePollView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addOption() {
|
private func addOption() {
|
||||||
withAnimation(.easeInOut(duration: 0.25)) {
|
poll.options.append(Draft.Poll.Option(""))
|
||||||
poll.options.append(Draft.Poll.Option(""))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -163,6 +154,7 @@ struct ComposePollOption: View {
|
||||||
Button(action: self.removeOption) {
|
Button(action: self.removeOption) {
|
||||||
Image(systemName: "minus.circle.fill")
|
Image(systemName: "minus.circle.fill")
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
.foregroundColor(poll.options.count == 1 ? .gray : .red)
|
.foregroundColor(poll.options.count == 1 ? .gray : .red)
|
||||||
.disabled(poll.options.count == 1)
|
.disabled(poll.options.count == 1)
|
||||||
.hoverEffect()
|
.hoverEffect()
|
||||||
|
@ -175,9 +167,7 @@ struct ComposePollOption: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func removeOption() {
|
private func removeOption() {
|
||||||
_ = withAnimation {
|
poll.options.remove(at: optionIndex)
|
||||||
poll.options.remove(at: optionIndex)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Checkbox: View {
|
struct Checkbox: View {
|
||||||
|
|
|
@ -17,18 +17,20 @@ struct ComposeReplyContentView: UIViewRepresentable {
|
||||||
|
|
||||||
let heightChanged: (CGFloat) -> Void
|
let heightChanged: (CGFloat) -> Void
|
||||||
|
|
||||||
func makeUIView(context: Context) -> ComposeReplyContentTextView {
|
func makeUIView(context: Context) -> UIViewType {
|
||||||
let view = ComposeReplyContentTextView()
|
let view = ComposeReplyContentTextView()
|
||||||
view.overrideMastodonController = mastodonController
|
view.overrideMastodonController = mastodonController
|
||||||
view.setTextFrom(status: status)
|
view.setTextFrom(status: status)
|
||||||
view.isUserInteractionEnabled = false
|
view.isUserInteractionEnabled = false
|
||||||
|
// scroll needs to be enabled, otherwise the text view never reports a contentSize greater than 1 line
|
||||||
|
view.isScrollEnabled = true
|
||||||
view.backgroundColor = .clear
|
view.backgroundColor = .clear
|
||||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: ComposeReplyContentTextView, context: Context) {
|
func updateUIView(_ uiView: UIViewType, context: Context) {
|
||||||
uiView.heightChanged = heightChanged
|
uiView.heightChanged = heightChanged
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,8 @@ import SwiftUI
|
||||||
|
|
||||||
struct ComposeReplyView: View {
|
struct ComposeReplyView: View {
|
||||||
let status: StatusMO
|
let status: StatusMO
|
||||||
let stackPadding: CGFloat
|
let rowTopInset: CGFloat
|
||||||
|
let globalFrameOutsideList: CGRect
|
||||||
|
|
||||||
@State private var displayNameHeight: CGFloat?
|
@State private var displayNameHeight: CGFloat?
|
||||||
@State private var contentHeight: CGFloat?
|
@State private var contentHeight: CGFloat?
|
||||||
|
@ -46,7 +47,11 @@ struct ComposeReplyView: View {
|
||||||
})
|
})
|
||||||
|
|
||||||
ComposeReplyContentView(status: status) { newHeight in
|
ComposeReplyContentView(status: status) { newHeight in
|
||||||
contentHeight = newHeight
|
// otherwise, with long in-reply-to statuses, the main content text view position seems not to update
|
||||||
|
// and it ends up partially behind the header
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
contentHeight = newHeight
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(height: contentHeight ?? 0)
|
.frame(height: contentHeight ?? 0)
|
||||||
}
|
}
|
||||||
|
@ -55,10 +60,12 @@ struct ComposeReplyView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func replyAvatarImage(geometry: GeometryProxy) -> some View {
|
private func replyAvatarImage(geometry: GeometryProxy) -> some View {
|
||||||
let scrollOffset = -geometry.frame(in: .named(ComposeView.coordinateSpaceOutsideOfScrollView)).minY
|
// using a coordinate space declared outside of the List doesn't work, so we do the math ourselves
|
||||||
|
let globalFrame = geometry.frame(in: .global)
|
||||||
|
let scrollOffset = -(globalFrame.minY - globalFrameOutsideList.minY)
|
||||||
|
|
||||||
// add stackPadding so that the image is always at least stackPadding away from the top
|
// add rowTopInset so that the image is always at least rowTopInset away from the top
|
||||||
var offset = scrollOffset + stackPadding
|
var offset = scrollOffset + rowTopInset
|
||||||
|
|
||||||
// offset can never be less than 0 (i.e., above the top of the in-reply-to content)
|
// offset can never be less than 0 (i.e., above the top of the in-reply-to content)
|
||||||
offset = max(offset, 0)
|
offset = max(offset, 0)
|
||||||
|
|
|
@ -69,6 +69,8 @@ struct WrappedTextView: UIViewRepresentable {
|
||||||
var textDidChange: ((UITextView) -> Void)?
|
var textDidChange: ((UITextView) -> Void)?
|
||||||
var font = UIFont.systemFont(ofSize: 20)
|
var font = UIFont.systemFont(ofSize: 20)
|
||||||
|
|
||||||
|
@Environment(\.isEnabled) private var isEnabled: Bool
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UITextView {
|
func makeUIView(context: Context) -> UITextView {
|
||||||
let textView = UITextView()
|
let textView = UITextView()
|
||||||
textView.delegate = context.coordinator
|
textView.delegate = context.coordinator
|
||||||
|
@ -82,6 +84,8 @@ struct WrappedTextView: UIViewRepresentable {
|
||||||
|
|
||||||
func updateUIView(_ uiView: UITextView, context: Context) {
|
func updateUIView(_ uiView: UITextView, context: Context) {
|
||||||
uiView.text = text
|
uiView.text = text
|
||||||
|
uiView.isEditable = isEnabled
|
||||||
|
context.coordinator.textView = uiView
|
||||||
context.coordinator.text = $text
|
context.coordinator.text = $text
|
||||||
context.coordinator.didChange = textDidChange
|
context.coordinator.didChange = textDidChange
|
||||||
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
||||||
|
@ -96,6 +100,7 @@ struct WrappedTextView: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
class Coordinator: NSObject, UITextViewDelegate, ComposeTextViewCaretScrolling {
|
class Coordinator: NSObject, UITextViewDelegate, ComposeTextViewCaretScrolling {
|
||||||
|
weak var textView: UITextView?
|
||||||
var text: Binding<String>
|
var text: Binding<String>
|
||||||
var didChange: ((UITextView) -> Void)?
|
var didChange: ((UITextView) -> Void)?
|
||||||
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
||||||
|
@ -103,6 +108,16 @@ struct WrappedTextView: UIViewRepresentable {
|
||||||
init(text: Binding<String>, didChange: ((UITextView) -> Void)?) {
|
init(text: Binding<String>, didChange: ((UITextView) -> Void)?) {
|
||||||
self.text = text
|
self.text = text
|
||||||
self.didChange = didChange
|
self.didChange = didChange
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func keyboardDidShow() {
|
||||||
|
guard let textView,
|
||||||
|
textView.isFirstResponder else { return }
|
||||||
|
ensureCursorVisible(textView: textView)
|
||||||
}
|
}
|
||||||
|
|
||||||
func textViewDidChange(_ textView: UITextView) {
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
|
|
@ -37,7 +37,7 @@ extension ComposeTextViewCaretScrolling {
|
||||||
rectToMakeVisible.origin.y -= cursorRect.height
|
rectToMakeVisible.origin.y -= cursorRect.height
|
||||||
rectToMakeVisible.size.height *= 3
|
rectToMakeVisible.size.height *= 3
|
||||||
|
|
||||||
let animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) {
|
||||||
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
|
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
|
||||||
}
|
}
|
||||||
self.caretScrollPositionAnimator = animator
|
self.caretScrollPositionAnimator = animator
|
||||||
|
|
|
@ -0,0 +1,156 @@
|
||||||
|
//
|
||||||
|
// ComposeToolbar.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/12/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
struct ComposeToolbar: View {
|
||||||
|
static let height: CGFloat = 44
|
||||||
|
private static let visibilityOptions: [MenuPicker.Option] = Status.Visibility.allCases.map { vis in
|
||||||
|
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
|
||||||
|
@EnvironmentObject private var uiState: ComposeUIState
|
||||||
|
@EnvironmentObject private var mastodonController: MastodonController
|
||||||
|
@ObservedObject private var preferences = Preferences.shared
|
||||||
|
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
|
||||||
|
@State private var minWidth: CGFloat?
|
||||||
|
@State private var realWidth: CGFloat?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Button("CW") {
|
||||||
|
draft.contentWarningEnabled.toggle()
|
||||||
|
}
|
||||||
|
.accessibilityLabel(draft.contentWarningEnabled ? "Remove content warning" : "Add content warning")
|
||||||
|
.padding(5)
|
||||||
|
.hoverEffect()
|
||||||
|
|
||||||
|
MenuPicker(selection: $draft.visibility, options: Self.visibilityOptions, buttonStyle: .iconOnly)
|
||||||
|
// // the button has a bunch of extra space by default, but combined with what we add it's too much
|
||||||
|
// .padding(.horizontal, -8)
|
||||||
|
|
||||||
|
if mastodonController.instanceFeatures.localOnlyPosts {
|
||||||
|
MenuPicker(selection: $draft.localOnly, options: [
|
||||||
|
.init(value: true, title: "Local-only", subtitle: "Only \(mastodonController.accountInfo!.instanceURL.host!)", image: UIImage(named: "link.broken")),
|
||||||
|
.init(value: false, title: "Federated", image: UIImage(systemName: "link"))
|
||||||
|
], buttonStyle: .iconOnly)
|
||||||
|
// .padding(.horizontal, -8)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let currentInput = uiState.currentInput, currentInput.toolbarElements.contains(.emojiPicker) {
|
||||||
|
Button(action: self.emojiPickerButtonPressed) {
|
||||||
|
Label("Insert custom emoji", systemImage: "face.smiling")
|
||||||
|
}
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.font(.system(size: imageSize))
|
||||||
|
.padding(5)
|
||||||
|
.hoverEffect()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let currentInput = uiState.currentInput,
|
||||||
|
currentInput.toolbarElements.contains(.formattingButtons),
|
||||||
|
preferences.statusContentType != .plain {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
ForEach(StatusFormat.allCases, id: \.rawValue) { format in
|
||||||
|
Button(action: self.formatAction(format)) {
|
||||||
|
if let imageName = format.imageName {
|
||||||
|
Image(systemName: imageName)
|
||||||
|
.font(.system(size: imageSize))
|
||||||
|
} else if let (str, attrs) = format.title {
|
||||||
|
let container = try! AttributeContainer(attrs, including: \.uiKit)
|
||||||
|
Text(AttributedString(str, attributes: container))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel(format.accessibilityLabel)
|
||||||
|
.padding(5)
|
||||||
|
.hoverEffect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: self.draftsButtonPressed) {
|
||||||
|
Text("Drafts")
|
||||||
|
}
|
||||||
|
.padding(5)
|
||||||
|
.hoverEffect()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.frame(minWidth: minWidth)
|
||||||
|
.background(GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
||||||
|
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
|
||||||
|
realWidth = width
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
|
||||||
|
.frame(height: Self.height)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(.regularMaterial, ignoresSafeAreaEdges: .bottom)
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
.background(GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
||||||
|
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
|
||||||
|
minWidth = width
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emojiPickerButtonPressed() {
|
||||||
|
guard uiState.autocompleteState == nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uiState.shouldEmojiAutocompletionBeginExpanded = true
|
||||||
|
uiState.currentInput?.beginAutocompletingEmoji()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func draftsButtonPressed() {
|
||||||
|
uiState.isShowingDraftsList = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatAction(_ format: StatusFormat) -> () -> Void {
|
||||||
|
{
|
||||||
|
uiState.currentInput?.applyFormat(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ToolbarWidthPrefKey: PreferenceKey {
|
||||||
|
static var defaultValue: CGFloat? = nil
|
||||||
|
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
|
||||||
|
value = nextValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@ViewBuilder
|
||||||
|
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self.scrollDisabled(disabled)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ComposeToolbar_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
ComposeToolbar(draft: Draft(accountID: ""))
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,10 +15,7 @@ protocol ComposeUIStateDelegate: AnyObject {
|
||||||
// @available(iOS, obsoleted: 16.0)
|
// @available(iOS, obsoleted: 16.0)
|
||||||
func presentAssetPickerSheet()
|
func presentAssetPickerSheet()
|
||||||
func presentComposeDrawing()
|
func presentComposeDrawing()
|
||||||
|
func selectDraft(_ draft: Draft)
|
||||||
func keyboardWillShow(accessoryView: UIView, notification: Notification)
|
|
||||||
func keyboardWillHide(accessoryView: UIView, notification: Notification)
|
|
||||||
func keyboardDidHide(accessoryView: UIView, notification: Notification)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ComposeUIState: ObservableObject {
|
class ComposeUIState: ObservableObject {
|
||||||
|
@ -27,8 +24,10 @@ class ComposeUIState: ObservableObject {
|
||||||
|
|
||||||
@Published var draft: Draft
|
@Published var draft: Draft
|
||||||
@Published var isShowingSaveDraftSheet = false
|
@Published var isShowingSaveDraftSheet = false
|
||||||
|
@Published var isShowingDraftsList = false
|
||||||
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
||||||
@Published var autocompleteState: AutocompleteState? = nil
|
@Published var autocompleteState: AutocompleteState? = nil
|
||||||
|
@Published var isDucking = false
|
||||||
|
|
||||||
var composeDrawingMode: ComposeDrawingMode?
|
var composeDrawingMode: ComposeDrawingMode?
|
||||||
|
|
||||||
|
|
|
@ -42,11 +42,13 @@ import Combine
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ComposeView: View {
|
struct ComposeView: View {
|
||||||
static let coordinateSpaceOutsideOfScrollView = "coordinateSpaceOutsideOfScrollView"
|
|
||||||
|
|
||||||
@ObservedObject var draft: Draft
|
@ObservedObject var draft: Draft
|
||||||
@EnvironmentObject var mastodonController: MastodonController
|
@ObservedObject var mastodonController: MastodonController
|
||||||
@EnvironmentObject var uiState: ComposeUIState
|
@ObservedObject var uiState: ComposeUIState
|
||||||
|
|
||||||
|
@State private var globalFrameOutsideList: CGRect = .zero
|
||||||
|
@State private var contentWarningBecomeFirstResponder = false
|
||||||
|
@State private var mainComposeTextViewBecomeFirstResponder = false
|
||||||
|
|
||||||
@OptionalStateObject private var poster: PostService?
|
@OptionalStateObject private var poster: PostService?
|
||||||
@State private var isShowingPostErrorAlert = false
|
@State private var isShowingPostErrorAlert = false
|
||||||
|
@ -58,42 +60,67 @@ struct ComposeView: View {
|
||||||
|
|
||||||
private let stackPadding: CGFloat = 8
|
private let stackPadding: CGFloat = 8
|
||||||
|
|
||||||
init(draft: Draft) {
|
init(mastodonController: MastodonController, uiState: ComposeUIState) {
|
||||||
self.draft = draft
|
self.draft = uiState.draft
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
self.uiState = uiState
|
||||||
}
|
}
|
||||||
|
|
||||||
var charactersRemaining: Int {
|
private var charactersRemaining: Int {
|
||||||
let limit = mastodonController.instanceFeatures.maxStatusChars
|
let limit = mastodonController.instanceFeatures.maxStatusChars
|
||||||
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
||||||
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: mastodonController.instance))
|
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: mastodonController.instance))
|
||||||
}
|
}
|
||||||
|
|
||||||
var requiresAttachmentDescriptions: Bool {
|
private var requiresAttachmentDescriptions: Bool {
|
||||||
guard Preferences.shared.requireAttachmentDescriptions else { return false }
|
guard Preferences.shared.requireAttachmentDescriptions else { return false }
|
||||||
let attachmentIds = draft.attachments.map(\.id)
|
let attachmentIds = draft.attachments.map(\.id)
|
||||||
return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) }
|
return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
var postButtonEnabled: Bool {
|
private var postButtonEnabled: Bool {
|
||||||
draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions && (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty })
|
draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions && (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty })
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
bodyWithoutEnvironment
|
||||||
|
.environmentObject(uiState)
|
||||||
|
.environmentObject(mastodonController)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bodyWithoutEnvironment: some View {
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
ScrollView(.vertical) {
|
mainList
|
||||||
mainStack
|
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||||
}
|
|
||||||
.coordinateSpace(name: ComposeView.coordinateSpaceOutsideOfScrollView)
|
|
||||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
|
||||||
|
|
||||||
if let poster = poster {
|
if let poster = poster {
|
||||||
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
||||||
WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
|
WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
|
||||||
}
|
}
|
||||||
|
|
||||||
autocompleteSuggestions
|
|
||||||
}
|
}
|
||||||
.navigationBarTitle("Compose")
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
|
if !uiState.isDucking {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
autocompleteSuggestions
|
||||||
|
.transition(.move(edge: .bottom))
|
||||||
|
.animation(.default, value: uiState.autocompleteState)
|
||||||
|
|
||||||
|
ComposeToolbar(draft: draft)
|
||||||
|
}
|
||||||
|
.transition(.move(edge: .bottom))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: GlobalFrameOutsideListPrefKey.self, value: proxy.frame(in: .global))
|
||||||
|
.onPreferenceChange(GlobalFrameOutsideListPrefKey.self) { frame in
|
||||||
|
globalFrameOutsideList = frame
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.navigationTitle(navTitle)
|
||||||
|
.sheet(isPresented: $uiState.isShowingDraftsList) {
|
||||||
|
DraftsView(currentDraft: draft, mastodonController: mastodonController)
|
||||||
|
}
|
||||||
.actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet)
|
.actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet)
|
||||||
.alert(isPresented: $isShowingPostErrorAlert) {
|
.alert(isPresented: $isShowingPostErrorAlert) {
|
||||||
Alert(
|
Alert(
|
||||||
|
@ -109,52 +136,67 @@ struct ComposeView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var autocompleteSuggestions: some View {
|
private var autocompleteSuggestions: some View {
|
||||||
VStack(spacing: 0) {
|
if let state = uiState.autocompleteState {
|
||||||
Spacer()
|
ComposeAutocompleteView(autocompleteState: state)
|
||||||
if let state = uiState.autocompleteState {
|
|
||||||
ComposeAutocompleteView(autocompleteState: state)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.transition(.move(edge: .bottom))
|
|
||||||
.animation(.default, value: uiState.autocompleteState)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var mainStack: some View {
|
private var mainList: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
List {
|
||||||
if let id = draft.inReplyToID,
|
if let id = draft.inReplyToID,
|
||||||
let status = mastodonController.persistentContainer.status(for: id) {
|
let status = mastodonController.persistentContainer.status(for: id) {
|
||||||
ComposeReplyView(
|
ComposeReplyView(
|
||||||
status: status,
|
status: status,
|
||||||
stackPadding: stackPadding
|
rowTopInset: 8,
|
||||||
|
globalFrameOutsideList: globalFrameOutsideList
|
||||||
)
|
)
|
||||||
|
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
header
|
header
|
||||||
|
.listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
|
||||||
if draft.contentWarningEnabled {
|
if draft.contentWarningEnabled {
|
||||||
ComposeEmojiTextField(text: $draft.contentWarning, placeholder: "Write your warning here")
|
ComposeEmojiTextField(
|
||||||
|
text: $draft.contentWarning,
|
||||||
|
placeholder: "Write your warning here",
|
||||||
|
becomeFirstResponder: $contentWarningBecomeFirstResponder,
|
||||||
|
focusNextView: $mainComposeTextViewBecomeFirstResponder
|
||||||
|
)
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
MainComposeTextView(
|
MainComposeTextView(
|
||||||
draft: draft
|
draft: draft,
|
||||||
|
becomeFirstResponder: $mainComposeTextViewBecomeFirstResponder
|
||||||
)
|
)
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
|
||||||
if let poll = draft.poll {
|
if let poll = draft.poll {
|
||||||
ComposePollView(draft: draft, poll: poll)
|
ComposePollView(draft: draft, poll: poll)
|
||||||
.transition(.opacity.combined(with: .asymmetric(insertion: .scale(scale: 0.5, anchor: .leading), removal: .scale(scale: 0.5, anchor: .trailing))))
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
ComposeAttachmentsList(
|
ComposeAttachmentsList(
|
||||||
draft: draft
|
draft: draft
|
||||||
)
|
)
|
||||||
// the list rows provide their own padding, so we cancel out the extra spacing from the VStack
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
|
||||||
.padding([.top, .bottom], -8)
|
|
||||||
}
|
}
|
||||||
|
.animation(.default, value: draft.poll?.options.count)
|
||||||
|
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||||
|
.listStyle(.plain)
|
||||||
.disabled(isPosting)
|
.disabled(isPosting)
|
||||||
.padding(stackPadding)
|
.onChange(of: draft.contentWarningEnabled) { newValue in
|
||||||
.padding(.bottom, uiState.autocompleteState != nil ? 46 : nil)
|
if newValue {
|
||||||
|
contentWarningBecomeFirstResponder = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
|
@ -168,6 +210,15 @@ struct ComposeView: View {
|
||||||
}.frame(height: 50)
|
}.frame(height: 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var navTitle: Text {
|
||||||
|
if let id = draft.inReplyToID,
|
||||||
|
let status = mastodonController.persistentContainer.status(for: id) {
|
||||||
|
return Text("Reply to @\(status.account.acct)")
|
||||||
|
} else {
|
||||||
|
return Text("New Post")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var cancelButton: some View {
|
private var cancelButton: some View {
|
||||||
Button(action: self.cancel) {
|
Button(action: self.cancel) {
|
||||||
Text("Cancel")
|
Text("Cancel")
|
||||||
|
@ -258,6 +309,13 @@ private extension View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct GlobalFrameOutsideListPrefKey: PreferenceKey {
|
||||||
|
static var defaultValue: CGRect = .zero
|
||||||
|
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
|
||||||
|
value = nextValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//struct ComposeView_Previews: PreviewProvider {
|
//struct ComposeView_Previews: PreviewProvider {
|
||||||
// static var previews: some View {
|
// static var previews: some View {
|
||||||
// ComposeView()
|
// ComposeView()
|
||||||
|
|
|
@ -0,0 +1,127 @@
|
||||||
|
//
|
||||||
|
// DraftsView.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/9/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DraftsView: View {
|
||||||
|
let currentDraft: Draft
|
||||||
|
// don't pass this in via the environment b/c it crashes on macOS (at least, in Designed for iPad mode) since the environment doesn't get propagated through the modal popup window or something
|
||||||
|
let mastodonController: MastodonController
|
||||||
|
@EnvironmentObject var uiState: ComposeUIState
|
||||||
|
@StateObject private var draftsManager = DraftsManager.shared
|
||||||
|
@State private var draftForDifferentReply: Draft?
|
||||||
|
|
||||||
|
private var visibleDrafts: [Draft] {
|
||||||
|
draftsManager.sorted.filter {
|
||||||
|
$0.accountID == mastodonController.accountInfo!.id && $0.id != currentDraft.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
List {
|
||||||
|
ForEach(visibleDrafts) { draft in
|
||||||
|
Button {
|
||||||
|
maybeSelectDraft(draft)
|
||||||
|
} label: {
|
||||||
|
DraftView(draft: draft)
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
draftsManager.remove(draft)
|
||||||
|
} label: {
|
||||||
|
Label("Delete Draft", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDrag {
|
||||||
|
let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: mastodonController.accountInfo!.id)
|
||||||
|
activity.displaysAuxiliaryScene = true
|
||||||
|
return NSItemProvider(object: activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete { indices in
|
||||||
|
indices
|
||||||
|
.map { visibleDrafts[$0] }
|
||||||
|
.forEach { draftsManager.remove($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle(Text("Drafts"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
uiState.isShowingDraftsList = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alertWithData("Different Reply", data: $draftForDifferentReply) { draft in
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
draftForDifferentReply = nil
|
||||||
|
}
|
||||||
|
Button("Restore Draft") {
|
||||||
|
uiState.delegate?.selectDraft(draft)
|
||||||
|
}
|
||||||
|
} message: { draft in
|
||||||
|
Text("The selected draft is a reply to a different post, do you wish to use it?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func maybeSelectDraft(_ draft: Draft) {
|
||||||
|
if draft.inReplyToID != currentDraft.inReplyToID,
|
||||||
|
currentDraft.hasContent {
|
||||||
|
draftForDifferentReply = draft
|
||||||
|
} else {
|
||||||
|
uiState.delegate?.selectDraft(draft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DraftView: View {
|
||||||
|
@ObservedObject private var draft: Draft
|
||||||
|
|
||||||
|
init(draft: Draft) {
|
||||||
|
self._draft = ObservedObject(wrappedValue: draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
if draft.contentWarningEnabled {
|
||||||
|
Text(draft.contentWarning)
|
||||||
|
.font(.body.bold())
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(draft.text)
|
||||||
|
.font(.body)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(draft.attachments) { attachment in
|
||||||
|
ComposeAttachmentImage(attachment: attachment, fullSize: false)
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.cornerRadius(5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(draft.lastModified.timeAgoString())
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//struct DraftsView_Previews: PreviewProvider {
|
||||||
|
// static var previews: some View {
|
||||||
|
// DraftsView(currentDraft: Draft(accountID: ""))
|
||||||
|
// }
|
||||||
|
//}
|
|
@ -17,6 +17,10 @@ struct MainComposeTextView: View {
|
||||||
if Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
|
if Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
|
||||||
return Text("Happy π day!")
|
return Text("Happy π day!")
|
||||||
}
|
}
|
||||||
|
} else if components.month == 9 && components.day == 5 {
|
||||||
|
// https://weirder.earth/@noracodes/109276419847254552
|
||||||
|
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
|
||||||
|
return Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic()
|
||||||
} else if components.month == 9 && components.day == 21 {
|
} else if components.month == 9 && components.day == 21 {
|
||||||
return Text("Do you remember?")
|
return Text("Do you remember?")
|
||||||
} else if components.month == 10 && components.day == 31 {
|
} else if components.month == 10 && components.day == 31 {
|
||||||
|
@ -31,7 +35,7 @@ struct MainComposeTextView: View {
|
||||||
|
|
||||||
let minHeight: CGFloat = 150
|
let minHeight: CGFloat = 150
|
||||||
@State private var height: CGFloat?
|
@State private var height: CGFloat?
|
||||||
@State private var becomeFirstResponder: Bool = false
|
@Binding var becomeFirstResponder: Bool
|
||||||
@State private var hasFirstAppeared = false
|
@State private var hasFirstAppeared = false
|
||||||
@ScaledMetric private var fontSize = 20
|
@ScaledMetric private var fontSize = 20
|
||||||
|
|
||||||
|
@ -44,6 +48,7 @@ struct MainComposeTextView: View {
|
||||||
.font(.system(size: fontSize))
|
.font(.system(size: fontSize))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.offset(x: 4, y: 8)
|
.offset(x: 4, y: 8)
|
||||||
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
MainComposeWrappedTextView(
|
MainComposeWrappedTextView(
|
||||||
|
@ -74,6 +79,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
||||||
|
|
||||||
@EnvironmentObject var uiState: ComposeUIState
|
@EnvironmentObject var uiState: ComposeUIState
|
||||||
@EnvironmentObject var mastodonController: MastodonController
|
@EnvironmentObject var mastodonController: MastodonController
|
||||||
|
@Environment(\.isEnabled) var isEnabled: Bool
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UITextView {
|
func makeUIView(context: Context) -> UITextView {
|
||||||
let textView = WrappedTextView()
|
let textView = WrappedTextView()
|
||||||
|
@ -94,6 +100,8 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
||||||
uiView.text = text
|
uiView.text = text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uiView.isEditable = isEnabled
|
||||||
|
|
||||||
context.coordinator.text = $text
|
context.coordinator.text = $text
|
||||||
context.coordinator.didChange = textDidChange
|
context.coordinator.didChange = textDidChange
|
||||||
context.coordinator.uiState = uiState
|
context.coordinator.uiState = uiState
|
||||||
|
@ -164,6 +172,16 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
||||||
self.text = text
|
self.text = text
|
||||||
self.didChange = didChange
|
self.didChange = didChange
|
||||||
self.uiState = uiState
|
self.uiState = uiState
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func keyboardDidShow() {
|
||||||
|
guard let textView,
|
||||||
|
textView.isFirstResponder else { return }
|
||||||
|
ensureCursorVisible(textView: textView)
|
||||||
}
|
}
|
||||||
|
|
||||||
func textViewDidChange(_ textView: UITextView) {
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
@ -218,7 +236,11 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
||||||
if range.length > 0 {
|
if range.length > 0 {
|
||||||
let formatMenu = suggestedActions[index] as! UIMenu
|
let formatMenu = suggestedActions[index] as! UIMenu
|
||||||
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
|
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
|
||||||
UIAction(title: fmt.accessibilityLabel, image: fmt.image) { [weak self] _ in
|
var image: UIImage?
|
||||||
|
if let imageName = fmt.imageName {
|
||||||
|
image = UIImage(systemName: imageName)
|
||||||
|
}
|
||||||
|
return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in
|
||||||
self?.applyFormat(fmt)
|
self?.applyFormat(fmt)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,142 +0,0 @@
|
||||||
//
|
|
||||||
// DraftsTableViewController.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 10/22/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
protocol DraftsTableViewControllerDelegate: AnyObject {
|
|
||||||
func draftSelectionCanceled()
|
|
||||||
func shouldSelectDraft(_ draft: Draft, completion: @escaping (Bool) -> Void)
|
|
||||||
func draftSelected(_ draft: Draft)
|
|
||||||
func draftSelectionCompleted()
|
|
||||||
}
|
|
||||||
|
|
||||||
class DraftsTableViewController: UITableViewController {
|
|
||||||
|
|
||||||
let account: LocalData.UserAccountInfo
|
|
||||||
let excludedDraft: Draft?
|
|
||||||
weak var delegate: DraftsTableViewControllerDelegate?
|
|
||||||
|
|
||||||
var drafts = [Draft]()
|
|
||||||
|
|
||||||
init(account: LocalData.UserAccountInfo, exclude: Draft? = nil) {
|
|
||||||
self.account = account
|
|
||||||
self.excludedDraft = exclude
|
|
||||||
|
|
||||||
super.init(nibName: "DraftsTableViewController", bundle: nil)
|
|
||||||
|
|
||||||
title = "Drafts"
|
|
||||||
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelPressed))
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder aDecoder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
tableView.rowHeight = UITableView.automaticDimension
|
|
||||||
tableView.estimatedRowHeight = 140
|
|
||||||
|
|
||||||
tableView.register(UINib(nibName: "DraftTableViewCell", bundle: nil), forCellReuseIdentifier: "draftCell")
|
|
||||||
|
|
||||||
tableView.dragDelegate = self
|
|
||||||
|
|
||||||
drafts = DraftsManager.shared.sorted.filter { (draft) in
|
|
||||||
draft.accountID == account.id && draft != excludedDraft
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func draft(for indexPath: IndexPath) -> Draft {
|
|
||||||
return drafts[indexPath.row]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Table View Data Source
|
|
||||||
|
|
||||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
||||||
return drafts.count
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
||||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: "draftCell", for: indexPath) as? DraftTableViewCell else { fatalError() }
|
|
||||||
|
|
||||||
cell.updateUI(for: draft(for: indexPath))
|
|
||||||
|
|
||||||
return cell
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
||||||
let draft = self.draft(for: indexPath)
|
|
||||||
func select() {
|
|
||||||
delegate?.draftSelected(draft)
|
|
||||||
dismiss(animated: true) {
|
|
||||||
self.delegate?.draftSelectionCompleted()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let delegate = delegate {
|
|
||||||
delegate.shouldSelectDraft(draft) { (shouldSelect) in
|
|
||||||
if shouldSelect {
|
|
||||||
select()
|
|
||||||
} else {
|
|
||||||
tableView.selectRow(at: nil, animated: true, scrollPosition: .none)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
select()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
|
|
||||||
return .delete
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
|
|
||||||
guard editingStyle == .delete else { return }
|
|
||||||
DraftsManager.shared.remove(draft(for: indexPath))
|
|
||||||
drafts.remove(at: indexPath.row)
|
|
||||||
tableView.deleteRows(at: [indexPath], with: .automatic)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
|
||||||
return UIContextMenuConfiguration(actionProvider: { _ in
|
|
||||||
return UIMenu(children: [
|
|
||||||
UIAction(title: "Delete Draft", image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [unowned self] _ in
|
|
||||||
DraftsManager.shared.remove(self.draft(for: indexPath))
|
|
||||||
drafts.remove(at: indexPath.row)
|
|
||||||
tableView.deleteRows(at: [indexPath], with: .automatic)
|
|
||||||
})
|
|
||||||
])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Interaction
|
|
||||||
|
|
||||||
@objc func cancelPressed() {
|
|
||||||
delegate?.draftSelectionCanceled()
|
|
||||||
dismiss(animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension DraftsTableViewController: UITableViewDragDelegate {
|
|
||||||
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
|
|
||||||
let draft = self.draft(for: indexPath)
|
|
||||||
let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: account.id)
|
|
||||||
activity.displaysAuxiliaryScene = true
|
|
||||||
let provider = NSItemProvider(object: activity)
|
|
||||||
return [UIDragItem(itemProvider: provider)]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14810.11" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
|
||||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
|
||||||
<dependencies>
|
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14766.13"/>
|
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
|
||||||
</dependencies>
|
|
||||||
<objects>
|
|
||||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="DraftsTableViewController" customModule="Tusker" customModuleProvider="target">
|
|
||||||
<connections>
|
|
||||||
<outlet property="view" destination="O5v-ea-iTS" id="sft-3K-LZf"/>
|
|
||||||
</connections>
|
|
||||||
</placeholder>
|
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
|
||||||
<tableView clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="O5v-ea-iTS">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
|
||||||
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
|
||||||
<point key="canvasLocation" x="-302" y="87"/>
|
|
||||||
</tableView>
|
|
||||||
</objects>
|
|
||||||
</document>
|
|
|
@ -70,6 +70,8 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(reloadLists), name: .listsChanged, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -178,7 +180,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
self.dataSource.apply(snapshot)
|
self.dataSource.apply(snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reloadLists() {
|
@objc private func reloadLists() {
|
||||||
let request = Client.getLists()
|
let request = Client.getLists()
|
||||||
mastodonController.run(request) { (response) in
|
mastodonController.run(request) { (response) in
|
||||||
guard case let .success(lists, _) = response else {
|
guard case let .success(lists, _) = response else {
|
||||||
|
@ -196,6 +198,23 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func listRenamed(_ notification: Foundation.Notification) {
|
||||||
|
let list = notification.userInfo!["list"] as! List
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
let existing = snapshot.itemIdentifiers(inSection: .lists).first(where: {
|
||||||
|
if case .list(let existingList) = $0, existingList.id == list.id {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if let existing {
|
||||||
|
snapshot.insertItems([.list(list)], afterItem: existing)
|
||||||
|
snapshot.deleteItems([existing])
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func fetchSavedHashtags() -> [SavedHashtag] {
|
private func fetchSavedHashtags() -> [SavedHashtag] {
|
||||||
let req = SavedHashtag.fetchRequest()
|
let req = SavedHashtag.fetchRequest()
|
||||||
|
@ -255,29 +274,17 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) {
|
private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) {
|
||||||
let titleFormat = NSLocalizedString("Are you sure you want to delete the '%@' list?", comment: "delete list alert title")
|
Task { @MainActor in
|
||||||
let title = String(format: titleFormat, list.title)
|
let service = DeleteListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
|
||||||
let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
|
if await service.run() {
|
||||||
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "delete list alert cancel button"), style: .cancel, handler: { (_) in
|
var snapshot = dataSource.snapshot()
|
||||||
completion(false)
|
|
||||||
}))
|
|
||||||
alert.addAction(UIAlertAction(title: NSLocalizedString("Delete List", comment: "delete list alert confirm button"), style: .destructive, handler: { (_) in
|
|
||||||
|
|
||||||
let request = List.delete(list)
|
|
||||||
self.mastodonController.run(request) { (response) in
|
|
||||||
guard case .success(_, _) = response else {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
var snapshot = self.dataSource.snapshot()
|
|
||||||
snapshot.deleteItems([.list(list)])
|
snapshot.deleteItems([.list(list)])
|
||||||
DispatchQueue.main.async {
|
await dataSource.apply(snapshot)
|
||||||
self.dataSource.apply(snapshot)
|
completion(true)
|
||||||
completion(true)
|
} else {
|
||||||
}
|
completion(false)
|
||||||
}
|
}
|
||||||
}))
|
}
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeSavedHashtag(_ hashtag: Hashtag) {
|
func removeSavedHashtag(_ hashtag: Hashtag) {
|
||||||
|
@ -356,28 +363,12 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
|
|
||||||
case .addList:
|
case .addList:
|
||||||
collectionView.deselectItem(at: indexPath, animated: true)
|
collectionView.deselectItem(at: indexPath, animated: true)
|
||||||
let alert = UIAlertController(title: NSLocalizedString("New List", comment: "new list alert title"), message: NSLocalizedString("Choose a title for your new list", comment: "new list alert message"), preferredStyle: .alert)
|
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true) }) { list in
|
||||||
alert.addTextField(configurationHandler: nil)
|
let listTimelineController = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
|
||||||
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "new list alert cancel button"), style: .cancel, handler: nil))
|
listTimelineController.presentEditOnAppear = true
|
||||||
alert.addAction(UIAlertAction(title: NSLocalizedString("Create List", comment: "new list create button"), style: .default, handler: { (_) in
|
self.show(listTimelineController, sender: nil)
|
||||||
guard let title = alert.textFields?.first?.text else {
|
}
|
||||||
fatalError()
|
service.run()
|
||||||
}
|
|
||||||
|
|
||||||
let request = Client.createList(title: title)
|
|
||||||
self.mastodonController.run(request) { (response) in
|
|
||||||
guard case let .success(list, _) = response else { fatalError() }
|
|
||||||
|
|
||||||
self.reloadLists()
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let listTimelineController = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
|
|
||||||
listTimelineController.presentEditOnAppear = true
|
|
||||||
self.show(listTimelineController, sender: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
present(alert, animated: true)
|
|
||||||
|
|
||||||
case let .savedHashtag(hashtag):
|
case let .savedHashtag(hashtag):
|
||||||
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
|
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
|
||||||
|
@ -505,7 +496,7 @@ extension ExploreViewController {
|
||||||
case (.profileDirectory, .profileDirectory):
|
case (.profileDirectory, .profileDirectory):
|
||||||
return true
|
return true
|
||||||
case let (.list(a), .list(b)):
|
case let (.list(a), .list(b)):
|
||||||
return a.id == b.id
|
return a.id == b.id && a.title == b.title
|
||||||
case (.addList, .addList):
|
case (.addList, .addList):
|
||||||
return true
|
return true
|
||||||
case let (.savedHashtag(a), .savedHashtag(b)):
|
case let (.savedHashtag(a), .savedHashtag(b)):
|
||||||
|
@ -536,6 +527,7 @@ extension ExploreViewController {
|
||||||
case let .list(list):
|
case let .list(list):
|
||||||
hasher.combine("list")
|
hasher.combine("list")
|
||||||
hasher.combine(list.id)
|
hasher.combine(list.id)
|
||||||
|
hasher.combine(list.title)
|
||||||
case .addList:
|
case .addList:
|
||||||
hasher.combine("addList")
|
hasher.combine("addList")
|
||||||
case let .savedHashtag(hashtag):
|
case let .savedHashtag(hashtag):
|
||||||
|
|
|
@ -112,7 +112,7 @@ class ProfileDirectoryViewController: UIViewController {
|
||||||
private func updateProfiles() {
|
private func updateProfiles() {
|
||||||
let scope = self.scope
|
let scope = self.scope
|
||||||
let order = self.order
|
let order = self.order
|
||||||
let local = scope == .everywhere
|
let local = scope == .instance
|
||||||
let request = Client.getFeaturedProfiles(local: local, order: order)
|
let request = Client.getFeaturedProfiles(local: local, order: order)
|
||||||
mastodonController.run(request) { (response) in
|
mastodonController.run(request) { (response) in
|
||||||
guard case let .success(accounts, _) = response,
|
guard case let .success(accounts, _) = response,
|
||||||
|
|
|
@ -29,8 +29,6 @@ class FindInstanceViewController: InstanceSelectorTableViewController {
|
||||||
|
|
||||||
delegate = self
|
delegate = self
|
||||||
|
|
||||||
searchController.hidesNavigationBarDuringPresentation = false
|
|
||||||
|
|
||||||
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed))
|
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,13 +13,13 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
|
|
||||||
let mastodonController: MastodonController
|
let mastodonController: MastodonController
|
||||||
|
|
||||||
let list: List
|
private var list: List
|
||||||
|
|
||||||
var dataSource: DataSource!
|
var dataSource: DataSource!
|
||||||
|
|
||||||
var nextRange: RequestRange?
|
var nextRange: RequestRange?
|
||||||
|
|
||||||
var searchResultsController: SearchResultsViewController!
|
var searchResultsController: EditListSearchResultsContainerViewController!
|
||||||
var searchController: UISearchController!
|
var searchController: UISearchController!
|
||||||
|
|
||||||
init(list: List, mastodonController: MastodonController) {
|
init(list: List, mastodonController: MastodonController) {
|
||||||
|
@ -28,7 +28,9 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
|
|
||||||
super.init(style: .plain)
|
super.init(style: .plain)
|
||||||
|
|
||||||
title = String(format: NSLocalizedString("Edit %@", comment: "edit list screen title"), list.title)
|
listChanged()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: list.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
|
@ -53,14 +55,23 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
})
|
})
|
||||||
dataSource.editListAccountsController = self
|
dataSource.editListAccountsController = self
|
||||||
|
|
||||||
searchResultsController = SearchResultsViewController(mastodonController: mastodonController, resultTypes: [.accounts])
|
searchResultsController = EditListSearchResultsContainerViewController(mastodonController: mastodonController) { [unowned self] accountID in
|
||||||
searchResultsController.delegate = self
|
Task {
|
||||||
|
await self.addAccount(id: accountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
searchController = UISearchController(searchResultsController: searchResultsController)
|
searchController = UISearchController(searchResultsController: searchResultsController)
|
||||||
searchController.hidesNavigationBarDuringPresentation = false
|
searchController.hidesNavigationBarDuringPresentation = false
|
||||||
searchController.searchResultsUpdater = searchResultsController
|
searchController.searchResultsUpdater = searchResultsController
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
searchController.scopeBarActivation = .onSearchActivation
|
||||||
|
} else {
|
||||||
|
searchController.automaticallyShowsScopeBar = true
|
||||||
|
}
|
||||||
searchController.searchBar.autocapitalizationType = .none
|
searchController.searchBar.autocapitalizationType = .none
|
||||||
searchController.searchBar.placeholder = NSLocalizedString("Search for accounts to add", comment: "edit list search field placeholder")
|
searchController.searchBar.placeholder = NSLocalizedString("Search for accounts to add", comment: "edit list search field placeholder")
|
||||||
searchController.searchBar.delegate = searchResultsController
|
searchController.searchBar.delegate = searchResultsController
|
||||||
|
searchController.searchBar.scopeButtonTitles = ["Everyone", "People You Follow"]
|
||||||
definesPresentationContext = true
|
definesPresentationContext = true
|
||||||
|
|
||||||
navigationItem.searchController = searchController
|
navigationItem.searchController = searchController
|
||||||
|
@ -68,28 +79,76 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
|
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed))
|
navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed))
|
||||||
|
|
||||||
loadAccounts()
|
Task {
|
||||||
|
await loadAccounts()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadAccounts() {
|
private func listChanged() {
|
||||||
let request = List.getAccounts(list)
|
title = String(format: NSLocalizedString("Edit %@", comment: "edit list screen title"), list.title)
|
||||||
mastodonController.run(request) { (response) in
|
}
|
||||||
guard case let .success(accounts, pagination) = response else {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@objc private func listRenamed(_ notification: Foundation.Notification) {
|
||||||
|
let list = notification.userInfo!["list"] as! List
|
||||||
|
self.list = list
|
||||||
|
self.listChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAccounts() async {
|
||||||
|
do {
|
||||||
|
let request = List.getAccounts(list)
|
||||||
|
let (accounts, pagination) = try await mastodonController.run(request)
|
||||||
self.nextRange = pagination?.older
|
self.nextRange = pagination?.older
|
||||||
|
|
||||||
self.mastodonController.persistentContainer.addAll(accounts: accounts) {
|
await withCheckedContinuation { continuation in
|
||||||
var snapshot = self.dataSource.snapshot()
|
mastodonController.persistentContainer.addAll(accounts: accounts) {
|
||||||
snapshot.deleteSections([.accounts])
|
continuation.resume()
|
||||||
snapshot.appendSections([.accounts])
|
|
||||||
snapshot.appendItems(accounts.map { .account(id: $0.id) })
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.dataSource.apply(snapshot)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var snapshot = self.dataSource.snapshot()
|
||||||
|
if snapshot.indexOfSection(.accounts) == nil {
|
||||||
|
snapshot.appendSections([.accounts])
|
||||||
|
} else {
|
||||||
|
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .accounts))
|
||||||
|
}
|
||||||
|
snapshot.appendItems(accounts.map { .account(id: $0.id) })
|
||||||
|
await dataSource.apply(snapshot)
|
||||||
|
} catch {
|
||||||
|
let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
await self.loadAccounts()
|
||||||
|
}
|
||||||
|
self.showToast(configuration: config, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addAccount(id: String) async {
|
||||||
|
do {
|
||||||
|
let req = List.add(list, accounts: [id])
|
||||||
|
_ = try await mastodonController.run(req)
|
||||||
|
self.searchController.isActive = false
|
||||||
|
await self.loadAccounts()
|
||||||
|
} catch {
|
||||||
|
let config = ToastConfiguration(from: error, with: "Error Adding Account", in: self) { [unowned self] toast in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
await self.addAccount(id: id)
|
||||||
|
}
|
||||||
|
self.showToast(configuration: config, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeAccount(id: String) async {
|
||||||
|
do {
|
||||||
|
let request = List.remove(list, accounts: [id])
|
||||||
|
_ = try await mastodonController.run(request)
|
||||||
|
await self.loadAccounts()
|
||||||
|
} catch {
|
||||||
|
let config = ToastConfiguration(from: error, with: "Error Removing Account", in: self) { [unowned self] toast in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
await self.removeAccount(id: id)
|
||||||
|
}
|
||||||
|
self.showToast(configuration: config, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,24 +161,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
// MARK: - Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
@objc func renameButtonPressed() {
|
@objc func renameButtonPressed() {
|
||||||
let alert = UIAlertController(title: NSLocalizedString("Rename List", comment: "rename list alert title"), message: nil, preferredStyle: .alert)
|
RenameListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }).run()
|
||||||
alert.addTextField { (textField) in
|
|
||||||
textField.text = self.list.title
|
|
||||||
}
|
|
||||||
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "rename list alert cancel button"), style: .cancel, handler: nil))
|
|
||||||
alert.addAction(UIAlertAction(title: NSLocalizedString("Rename", comment: "renaem list alert rename button"), style: .default, handler: { (_) in
|
|
||||||
guard let text = alert.textFields?.first?.text else {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
let request = List.update(self.list, title: text)
|
|
||||||
self.mastodonController.run(request) { (response) in
|
|
||||||
guard case .success(_, _) = response else {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
// todo: show success message somehow
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -145,29 +187,8 @@ extension EditListAccountsViewController {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = List.remove(editListAccountsController!.list, accounts: [id])
|
Task {
|
||||||
editListAccountsController!.mastodonController.run(request) { (response) in
|
await self.editListAccountsController?.removeAccount(id: id)
|
||||||
guard case .success(_, _) = response else {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
self.editListAccountsController?.loadAccounts()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EditListAccountsViewController: SearchResultsViewControllerDelegate {
|
|
||||||
func selectedSearchResult(account accountID: String) {
|
|
||||||
let request = List.add(list, accounts: [accountID])
|
|
||||||
mastodonController.run(request) { (response) in
|
|
||||||
guard case .success(_, _) = response else {
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
self.loadAccounts()
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.searchController.isActive = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,178 @@
|
||||||
|
//
|
||||||
|
// EditListSearchFollowingViewController.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/11/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
class EditListSearchFollowingViewController: EnhancedTableViewController {
|
||||||
|
|
||||||
|
private let mastodonController: MastodonController
|
||||||
|
private let didSelectAccount: (String) -> Void
|
||||||
|
|
||||||
|
private var dataSource: UITableViewDiffableDataSource<Section, String>!
|
||||||
|
|
||||||
|
private var query: String?
|
||||||
|
private var accountIDs: [String] = []
|
||||||
|
private var nextRange: RequestRange?
|
||||||
|
|
||||||
|
init(mastodonController: MastodonController, didSelectAccount: @escaping (String) -> Void) {
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
self.didSelectAccount = didSelectAccount
|
||||||
|
|
||||||
|
super.init(style: .grouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: "accountCell")
|
||||||
|
dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, itemIdentifier in
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "accountCell", for: indexPath) as! AccountTableViewCell
|
||||||
|
cell.delegate = self
|
||||||
|
cell.updateUI(accountID: itemIdentifier)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
if dataSource.snapshot().numberOfItems == 0 {
|
||||||
|
Task {
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
print("will display: \(indexPath)")
|
||||||
|
if indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - 1 {
|
||||||
|
Task {
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
guard let id = dataSource.itemIdentifier(for: indexPath) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
didSelectAccount(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
do {
|
||||||
|
let ownAccount = try await mastodonController.getOwnAccount()
|
||||||
|
let req = Account.getFollowing(ownAccount.id, range: nextRange ?? .default)
|
||||||
|
let (following, pagination) = try await mastodonController.run(req)
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
mastodonController.persistentContainer.addAll(accounts: following) {
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accountIDs.append(contentsOf: following.lazy.map(\.id))
|
||||||
|
nextRange = pagination?.older
|
||||||
|
updateDataSource(appending: following.map(\.id))
|
||||||
|
} catch {
|
||||||
|
let config = ToastConfiguration(from: error, with: "Error Loading Following", in: self) { toast in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
await self.load()
|
||||||
|
}
|
||||||
|
self.showToast(configuration: config, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateDataSourceForQueryChanged() {
|
||||||
|
guard let query, !query.isEmpty else {
|
||||||
|
let snapshot = NSDiffableDataSourceSnapshot<Section, String>()
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let ids = filterAccounts(ids: accountIDs, with: query)
|
||||||
|
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
if snapshot.indexOfSection(.accounts) == nil {
|
||||||
|
snapshot.appendSections([.accounts])
|
||||||
|
} else {
|
||||||
|
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .accounts))
|
||||||
|
}
|
||||||
|
snapshot.appendItems(ids)
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
|
||||||
|
// if there aren't any results for the current query, try to load more
|
||||||
|
if ids.isEmpty {
|
||||||
|
Task {
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateDataSource(appending ids: [String]) {
|
||||||
|
guard let query, !query.isEmpty else {
|
||||||
|
let snapshot = NSDiffableDataSourceSnapshot<Section, String>()
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let ids = filterAccounts(ids: ids, with: query)
|
||||||
|
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
if snapshot.indexOfSection(.accounts) == nil {
|
||||||
|
snapshot.appendSections([.accounts])
|
||||||
|
}
|
||||||
|
let existing = snapshot.itemIdentifiers(inSection: .accounts)
|
||||||
|
snapshot.appendItems(ids.filter { !existing.contains($0) })
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
|
||||||
|
// if there aren't any results for the current query, try to load more
|
||||||
|
if ids.isEmpty {
|
||||||
|
Task {
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filterAccounts(ids: [String], with query: String) -> [String] {
|
||||||
|
let req = AccountMO.fetchRequest()
|
||||||
|
req.predicate = NSPredicate(format: "id in %@", ids)
|
||||||
|
let accounts = try! mastodonController.persistentContainer.viewContext.fetch(req)
|
||||||
|
|
||||||
|
return accounts
|
||||||
|
.map { (account) -> (AccountMO, Bool) in
|
||||||
|
let displayNameMatch = FuzzyMatcher.match(pattern: query, str: account.displayNameWithoutCustomEmoji)
|
||||||
|
let usernameMatch = FuzzyMatcher.match(pattern: query, str: account.acct)
|
||||||
|
return (account, displayNameMatch.matched || usernameMatch.matched)
|
||||||
|
}
|
||||||
|
.filter(\.1)
|
||||||
|
.map(\.0.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateQuery(_ query: String) {
|
||||||
|
self.query = query
|
||||||
|
updateDataSourceForQueryChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EditListSearchFollowingViewController {
|
||||||
|
enum Section {
|
||||||
|
case accounts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EditListSearchFollowingViewController: TuskerNavigationDelegate {
|
||||||
|
var apiController: MastodonController! { mastodonController }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EditListSearchFollowingViewController: MenuActionProvider {
|
||||||
|
}
|
|
@ -0,0 +1,115 @@
|
||||||
|
//
|
||||||
|
// EditListSearchResultsContainerViewController.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/11/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class EditListSearchResultsContainerViewController: UIViewController {
|
||||||
|
|
||||||
|
private let mastodonController: MastodonController
|
||||||
|
private let didSelectAccount: (String) -> Void
|
||||||
|
|
||||||
|
private let searchResultsController: SearchResultsViewController
|
||||||
|
private let searchFollowingController: EditListSearchFollowingViewController
|
||||||
|
|
||||||
|
var mode = Mode.search {
|
||||||
|
willSet {
|
||||||
|
currentViewController.removeViewAndController()
|
||||||
|
}
|
||||||
|
didSet {
|
||||||
|
embedChild(currentViewController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var currentViewController: UIViewController {
|
||||||
|
switch mode {
|
||||||
|
case .search:
|
||||||
|
return searchResultsController
|
||||||
|
case .following:
|
||||||
|
return searchFollowingController
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentQuery: String?
|
||||||
|
private var searchSubject = PassthroughSubject<String?, Never>()
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(mastodonController: MastodonController, didSelectAccount: @escaping (String) -> Void) {
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
self.didSelectAccount = didSelectAccount
|
||||||
|
|
||||||
|
self.searchResultsController = SearchResultsViewController(mastodonController: mastodonController, resultTypes: [.accounts])
|
||||||
|
self.searchFollowingController = EditListSearchFollowingViewController(mastodonController: mastodonController, didSelectAccount: didSelectAccount)
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
self.searchResultsController.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
embedChild(currentViewController)
|
||||||
|
|
||||||
|
searchSubject
|
||||||
|
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
||||||
|
.sink { [unowned self] in self.performSearch(query: $0) }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func performSearch(query: String?) {
|
||||||
|
guard var query = query?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if query.starts(with: "@") {
|
||||||
|
query = String(query.dropFirst())
|
||||||
|
}
|
||||||
|
guard query != self.currentQuery else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.currentQuery = query
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case .search:
|
||||||
|
searchResultsController.performSearch(query: query)
|
||||||
|
case .following:
|
||||||
|
searchFollowingController.updateQuery(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Mode: Equatable {
|
||||||
|
case search, following
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EditListSearchResultsContainerViewController: UISearchResultsUpdating {
|
||||||
|
func updateSearchResults(for searchController: UISearchController) {
|
||||||
|
searchSubject.send(searchController.searchBar.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EditListSearchResultsContainerViewController: UISearchBarDelegate {
|
||||||
|
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
|
||||||
|
performSearch(query: searchBar.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
|
||||||
|
mode = selectedScope == 0 ? .search : .following
|
||||||
|
performSearch(query: searchBar.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EditListSearchResultsContainerViewController: SearchResultsViewControllerDelegate {
|
||||||
|
func selectedSearchResult(account accountID: String) {
|
||||||
|
didSelectAccount(accountID)
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,7 +11,7 @@ import Pachyderm
|
||||||
|
|
||||||
class ListTimelineViewController: TimelineViewController {
|
class ListTimelineViewController: TimelineViewController {
|
||||||
|
|
||||||
let list: List
|
private(set) var list: List
|
||||||
|
|
||||||
var presentEditOnAppear = false
|
var presentEditOnAppear = false
|
||||||
|
|
||||||
|
@ -20,7 +20,9 @@ class ListTimelineViewController: TimelineViewController {
|
||||||
|
|
||||||
super.init(for: .list(id: list.id), mastodonController: mastodonController)
|
super.init(for: .list(id: list.id), mastodonController: mastodonController)
|
||||||
|
|
||||||
title = list.title
|
listChanged()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: list.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder aDecoder: NSCoder) {
|
required init?(coder aDecoder: NSCoder) {
|
||||||
|
@ -41,6 +43,16 @@ class ListTimelineViewController: TimelineViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func listChanged() {
|
||||||
|
title = list.title
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func listRenamed(_ notification: Foundation.Notification) {
|
||||||
|
let list = notification.userInfo!["list"] as! List
|
||||||
|
self.list = list
|
||||||
|
self.listChanged()
|
||||||
|
}
|
||||||
|
|
||||||
func presentEdit(animated: Bool) {
|
func presentEdit(animated: Bool) {
|
||||||
let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController)
|
let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController)
|
||||||
editListAccountsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed))
|
editListAccountsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed))
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
//
|
||||||
|
// Duckable+Root.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/7/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Duckable
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
extension DuckableContainerViewController: TuskerRootViewController {
|
||||||
|
func presentCompose() {
|
||||||
|
(child as? TuskerRootViewController)?.presentCompose()
|
||||||
|
}
|
||||||
|
|
||||||
|
func select(tab: MainTabBarViewController.Tab) {
|
||||||
|
(child as? TuskerRootViewController)?.select(tab: tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
||||||
|
return (child as? TuskerRootViewController)?.getTabController(tab: tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
func performSearch(query: String) {
|
||||||
|
(child as? TuskerRootViewController)?.performSearch(query: query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentPreferences(completion: (() -> Void)?) {
|
||||||
|
(child as? TuskerRootViewController)?.presentPreferences(completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
||||||
|
(child as? TuskerRootViewController)?.handleStatusBarTapped(xPosition: xPosition) ?? .continue
|
||||||
|
}
|
||||||
|
}
|
|
@ -99,6 +99,8 @@ class MainSidebarViewController: UIViewController {
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(reloadLists), name: .listsChanged, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||||
|
|
||||||
onViewDidLoad?()
|
onViewDidLoad?()
|
||||||
|
@ -201,7 +203,7 @@ class MainSidebarViewController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reloadLists() {
|
@objc private func reloadLists() {
|
||||||
let request = Client.getLists()
|
let request = Client.getLists()
|
||||||
mastodonController.run(request) { [weak self] (response) in
|
mastodonController.run(request) { [weak self] (response) in
|
||||||
guard let self = self, case let .success(lists, _) = response else { return }
|
guard let self = self, case let .success(lists, _) = response else { return }
|
||||||
|
@ -223,6 +225,23 @@ class MainSidebarViewController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func listRenamed(_ notification: Foundation.Notification) {
|
||||||
|
let list = notification.userInfo!["list"] as! List
|
||||||
|
var snapshot = dataSource.snapshot()
|
||||||
|
let existing = snapshot.itemIdentifiers(inSection: .lists).first(where: {
|
||||||
|
if case .list(let existingList) = $0, existingList.id == list.id {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if let existing {
|
||||||
|
snapshot.insertItems([.list(list)], afterItem: existing)
|
||||||
|
snapshot.deleteItems([existing])
|
||||||
|
dataSource.apply(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func fetchSavedHashtags() -> [SavedHashtag] {
|
private func fetchSavedHashtags() -> [SavedHashtag] {
|
||||||
let req = SavedHashtag.fetchRequest()
|
let req = SavedHashtag.fetchRequest()
|
||||||
|
@ -297,28 +316,12 @@ class MainSidebarViewController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo: deduplicate with ExploreViewController
|
|
||||||
private func showAddList() {
|
private func showAddList() {
|
||||||
let alert = UIAlertController(title: "New List", message: "Choose a title for your new list", preferredStyle: .alert)
|
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true
|
||||||
alert.addTextField(configurationHandler: nil)
|
) }) { list in
|
||||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
|
self.sidebarDelegate?.sidebar(self, didSelectItem: .list(list))
|
||||||
alert.addAction(UIAlertAction(title: "Create List", style: .default, handler: { (_) in
|
}
|
||||||
guard let title = alert.textFields?.first?.text else {
|
service.run()
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
let request = Client.createList(title: title)
|
|
||||||
self.mastodonController.run(request) { (response) in
|
|
||||||
guard case let .success(list, _) = response else { fatalError() }
|
|
||||||
|
|
||||||
self.reloadLists()
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.sidebarDelegate?.sidebar(self, didSelectItem: .list(list))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo: deduplicate with ExploreViewController
|
// todo: deduplicate with ExploreViewController
|
||||||
|
@ -551,11 +554,22 @@ extension MainSidebarViewController: UICollectionViewDelegate {
|
||||||
}
|
}
|
||||||
activity.displaysAuxiliaryScene = true
|
activity.displaysAuxiliaryScene = true
|
||||||
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) in
|
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) in
|
||||||
return UIMenu(children: [
|
var actions: [UIAction] = [
|
||||||
UIWindowScene.ActivationAction({ action in
|
UIWindowScene.ActivationAction({ action in
|
||||||
return UIWindowScene.ActivationConfiguration(userActivity: activity)
|
return UIWindowScene.ActivationConfiguration(userActivity: activity)
|
||||||
}),
|
}),
|
||||||
])
|
]
|
||||||
|
|
||||||
|
if case .list(let list) = item {
|
||||||
|
actions.append(UIAction(title: "Delete List", image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [unowned self] _ in
|
||||||
|
Task {
|
||||||
|
let service = DeleteListService(list: list, mastodonController: self.mastodonController, present: { self.present($0, animated: true) })
|
||||||
|
await service.run()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return UIMenu(children: actions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -373,19 +373,13 @@ fileprivate extension MainSidebarViewController.Item {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension MainSplitViewController: TuskerNavigationDelegate {
|
||||||
|
var apiController: MastodonController! { mastodonController }
|
||||||
|
}
|
||||||
|
|
||||||
extension MainSplitViewController: TuskerRootViewController {
|
extension MainSplitViewController: TuskerRootViewController {
|
||||||
@objc func presentCompose() {
|
@objc func presentCompose() {
|
||||||
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
|
self.compose()
|
||||||
let compose = UserActivityManager.newPostActivity(mentioning: nil, accountID: mastodonController.accountInfo!.id)
|
|
||||||
let options = UIWindowScene.ActivationRequestOptions()
|
|
||||||
options.preferredPresentationStyle = .prominent
|
|
||||||
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil)
|
|
||||||
} else {
|
|
||||||
let vc = ComposeHostingController(mastodonController: mastodonController)
|
|
||||||
let nav = EnhancedNavigationViewController(rootViewController: vc)
|
|
||||||
nav.presentationController?.delegate = vc
|
|
||||||
present(nav, animated: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func select(tab: MainTabBarViewController.Tab) {
|
func select(tab: MainTabBarViewController.Tab) {
|
||||||
|
|
|
@ -228,19 +228,13 @@ extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension MainTabBarViewController: TuskerNavigationDelegate {
|
||||||
|
var apiController: MastodonController! { mastodonController }
|
||||||
|
}
|
||||||
|
|
||||||
extension MainTabBarViewController: TuskerRootViewController {
|
extension MainTabBarViewController: TuskerRootViewController {
|
||||||
@objc func presentCompose() {
|
@objc func presentCompose() {
|
||||||
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
|
compose()
|
||||||
let compose = UserActivityManager.newPostActivity(mentioning: nil, accountID: mastodonController.accountInfo!.id)
|
|
||||||
let options = UIWindowScene.ActivationRequestOptions()
|
|
||||||
options.preferredPresentationStyle = .prominent
|
|
||||||
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil)
|
|
||||||
} else {
|
|
||||||
let vc = ComposeHostingController(mastodonController: mastodonController)
|
|
||||||
let nav = EnhancedNavigationViewController(rootViewController: vc)
|
|
||||||
nav.presentationController?.delegate = vc
|
|
||||||
present(nav, animated: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func select(tab: Tab) {
|
func select(tab: Tab) {
|
||||||
|
|
|
@ -0,0 +1,145 @@
|
||||||
|
//
|
||||||
|
// MuteAccountView.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/11/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
struct MuteAccountView: View {
|
||||||
|
private static let durationOptions: [MenuPicker<TimeInterval>.Option] = {
|
||||||
|
let f = DateComponentsFormatter()
|
||||||
|
f.maximumUnitCount = 1
|
||||||
|
f.unitsStyle = .full
|
||||||
|
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
|
||||||
|
|
||||||
|
let durations: [TimeInterval] = [
|
||||||
|
30 * 60,
|
||||||
|
60 * 60,
|
||||||
|
6 * 60 * 60,
|
||||||
|
24 * 60 * 60,
|
||||||
|
3 * 24 * 60 * 60,
|
||||||
|
7 * 60 * 60 * 60,
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
.init(value: 0, title: "Forever")
|
||||||
|
] + durations.map { .init(value: $0, title: f.string(from: $0)!) }
|
||||||
|
}()
|
||||||
|
|
||||||
|
let account: AccountMO
|
||||||
|
let mastodonController: MastodonController
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss: DismissAction
|
||||||
|
@ObservedObject private var preferences = Preferences.shared
|
||||||
|
@State private var muteNotifications = true
|
||||||
|
@State private var duration: TimeInterval = 0
|
||||||
|
@State private var isMuting = false
|
||||||
|
@State private var error: Error?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
navigationViewContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var navigationViewContent: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
ComposeAvatarImageView(url: account.avatar)
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
AccountDisplayNameLabel(account: account, textStyle: .headline, emojiSize: 17)
|
||||||
|
Text("@\(account.acct)")
|
||||||
|
.fontWeight(.light)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 50)
|
||||||
|
.listRowBackground(EmptyView())
|
||||||
|
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||||
|
}
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: $muteNotifications) {
|
||||||
|
Text("Hide notifications from this person")
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
if muteNotifications {
|
||||||
|
Text("This user's posts and notifications will be hidden.")
|
||||||
|
} else {
|
||||||
|
Text("This user's posts will be hidden from your timeline. You can still receive notifications from them.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker(selection: $duration) {
|
||||||
|
ForEach(MuteAccountView.durationOptions, id: \.value) { option in
|
||||||
|
Text(option.title).tag(option.value)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Duration")
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
if duration != 0 {
|
||||||
|
Text("The mute will automatically be removed after the selected time.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: self.mute) {
|
||||||
|
if isMuting {
|
||||||
|
HStack {
|
||||||
|
Text("Muting User")
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Mute User")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isMuting)
|
||||||
|
}
|
||||||
|
.alertWithData("Erorr Muting", data: $error, actions: { error in
|
||||||
|
Button("Ok") {}
|
||||||
|
}, message: { error in
|
||||||
|
Text(error.localizedDescription)
|
||||||
|
})
|
||||||
|
.navigationTitle("Mute \(account.displayOrUserName)")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
self.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mute() {
|
||||||
|
isMuting = true
|
||||||
|
let req = Account.mute(account.id, notifications: muteNotifications)
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let (relationship, _) = try await mastodonController.run(req)
|
||||||
|
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
||||||
|
self.dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error
|
||||||
|
isMuting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//struct MuteAccountView_Previews: PreviewProvider {
|
||||||
|
// static var previews: some View {
|
||||||
|
// MuteAccountView()
|
||||||
|
// }
|
||||||
|
//}
|
|
@ -63,6 +63,7 @@ class InstanceSelectorTableViewController: UITableViewController {
|
||||||
appearance.configureWithDefaultBackground()
|
appearance.configureWithDefaultBackground()
|
||||||
navigationItem.scrollEdgeAppearance = appearance
|
navigationItem.scrollEdgeAppearance = appearance
|
||||||
|
|
||||||
|
tableView.keyboardDismissMode = .interactive
|
||||||
tableView.register(UINib(nibName: "InstanceTableViewCell", bundle: .main), forCellReuseIdentifier: instanceCell)
|
tableView.register(UINib(nibName: "InstanceTableViewCell", bundle: .main), forCellReuseIdentifier: instanceCell)
|
||||||
tableView.rowHeight = UITableView.automaticDimension
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
tableView.estimatedRowHeight = 120
|
tableView.estimatedRowHeight = 120
|
||||||
|
@ -84,7 +85,11 @@ class InstanceSelectorTableViewController: UITableViewController {
|
||||||
searchController = UISearchController(searchResultsController: nil)
|
searchController = UISearchController(searchResultsController: nil)
|
||||||
searchController.searchResultsUpdater = self
|
searchController.searchResultsUpdater = self
|
||||||
searchController.obscuresBackgroundDuringPresentation = false
|
searchController.obscuresBackgroundDuringPresentation = false
|
||||||
|
searchController.hidesNavigationBarDuringPresentation = false
|
||||||
searchController.searchBar.searchTextField.autocapitalizationType = .none
|
searchController.searchBar.searchTextField.autocapitalizationType = .none
|
||||||
|
searchController.searchBar.searchTextField.keyboardType = .URL
|
||||||
|
searchController.searchBar.showsCancelButton = false
|
||||||
|
searchController.searchBar.placeholder = "Search or enter a URL"
|
||||||
navigationItem.searchController = searchController
|
navigationItem.searchController = searchController
|
||||||
navigationItem.hidesSearchBarWhenScrolling = false
|
navigationItem.hidesSearchBarWhenScrolling = false
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
|
|
|
@ -31,7 +31,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
private(set) var headerCell: ProfileHeaderCollectionViewCell?
|
private(set) var headerCell: ProfileHeaderCollectionViewCell?
|
||||||
|
|
||||||
private var state: State = .unloaded
|
private(set) var state: State = .unloaded
|
||||||
|
|
||||||
init(accountID: String?, kind: Kind, owner: ProfileViewController) {
|
init(accountID: String?, kind: Kind, owner: ProfileViewController) {
|
||||||
self.accountID = accountID
|
self.accountID = accountID
|
||||||
|
@ -99,8 +99,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
Task {
|
Task {
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
case .loading:
|
|
||||||
break
|
|
||||||
case .loaded, .setupInitialSnapshot:
|
case .loaded, .setupInitialSnapshot:
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
snapshot.reconfigureItems([.header(id)])
|
snapshot.reconfigureItems([.header(id)])
|
||||||
|
@ -133,6 +131,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
view.updateUI(for: id)
|
view.updateUI(for: id)
|
||||||
view.pagesSegmentedControl.selectedSegmentIndex = self.owner?.currentIndex ?? 0
|
view.pagesSegmentedControl.selectedSegmentIndex = self.owner?.currentIndex ?? 0
|
||||||
cell.addHeader(view)
|
cell.addHeader(view)
|
||||||
|
case .useExistingView(let view):
|
||||||
|
cell.addHeader(view)
|
||||||
case .placeholder(height: let height):
|
case .placeholder(height: let height):
|
||||||
_ = cell.addConstraint(height: height)
|
_ = cell.addConstraint(height: height)
|
||||||
}
|
}
|
||||||
|
@ -174,13 +174,11 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
private func load() async {
|
private func load() async {
|
||||||
guard isViewLoaded,
|
guard isViewLoaded,
|
||||||
let accountID,
|
let accountID,
|
||||||
case .unloaded = state,
|
state == .unloaded,
|
||||||
mastodonController.persistentContainer.account(for: accountID) != nil else {
|
mastodonController.persistentContainer.account(for: accountID) != nil else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state = .loading
|
|
||||||
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
snapshot.appendSections([.header, .pinned, .statuses])
|
snapshot.appendSections([.header, .pinned, .statuses])
|
||||||
snapshot.appendItems([.header(accountID)], toSection: .header)
|
snapshot.appendItems([.header(accountID)], toSection: .header)
|
||||||
|
@ -192,6 +190,9 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
await tryLoadPinned()
|
await tryLoadPinned()
|
||||||
|
|
||||||
state = .loaded
|
state = .loaded
|
||||||
|
|
||||||
|
// remove any content inset that was added when switching pages to this VC
|
||||||
|
collectionView.contentInset = .zero
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tryLoadPinned() async {
|
private func tryLoadPinned() async {
|
||||||
|
@ -260,7 +261,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
extension ProfileStatusesViewController {
|
extension ProfileStatusesViewController {
|
||||||
enum State {
|
enum State {
|
||||||
case unloaded
|
case unloaded
|
||||||
case loading
|
|
||||||
case setupInitialSnapshot
|
case setupInitialSnapshot
|
||||||
case loaded
|
case loaded
|
||||||
}
|
}
|
||||||
|
@ -271,7 +271,7 @@ extension ProfileStatusesViewController {
|
||||||
case statuses, withReplies, onlyMedia
|
case statuses, withReplies, onlyMedia
|
||||||
}
|
}
|
||||||
enum HeaderMode {
|
enum HeaderMode {
|
||||||
case createView, placeholder(height: CGFloat)
|
case createView, useExistingView(ProfileHeaderView), placeholder(height: CGFloat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
class ProfileViewController: UIPageViewController {
|
class ProfileViewController: UIViewController {
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
weak var mastodonController: MastodonController!
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ class ProfileViewController: UIPageViewController {
|
||||||
self.accountID = accountID
|
self.accountID = accountID
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
|
||||||
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
self.pageControllers = [
|
self.pageControllers = [
|
||||||
.init(accountID: accountID, kind: .statuses, owner: self),
|
.init(accountID: accountID, kind: .statuses, owner: self),
|
||||||
|
@ -146,26 +146,32 @@ class ProfileViewController: UIPageViewController {
|
||||||
|
|
||||||
state = .animating
|
state = .animating
|
||||||
|
|
||||||
let direction: UIPageViewController.NavigationDirection
|
|
||||||
if currentIndex == nil || index - currentIndex > 0 {
|
|
||||||
direction = .forward
|
|
||||||
} else {
|
|
||||||
direction = .reverse
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let old = viewControllers?.first as? ProfileStatusesViewController else {
|
|
||||||
// if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary
|
|
||||||
pageControllers[index].initialHeaderMode = .createView
|
|
||||||
setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in
|
|
||||||
self.state = .idle
|
|
||||||
completion?(finished)
|
|
||||||
}
|
|
||||||
currentIndex = index
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let new = pageControllers[index]
|
let new = pageControllers[index]
|
||||||
|
|
||||||
currentIndex = index
|
guard let currentIndex else {
|
||||||
|
assert(!animated)
|
||||||
|
// if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary
|
||||||
|
new.initialHeaderMode = .createView
|
||||||
|
new.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
embedChild(new)
|
||||||
|
self.currentIndex = index
|
||||||
|
state = .idle
|
||||||
|
completion?(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let direction: CGFloat
|
||||||
|
if index - currentIndex > 0 {
|
||||||
|
direction = 1 // forward
|
||||||
|
} else {
|
||||||
|
direction = -1 // reverse
|
||||||
|
}
|
||||||
|
|
||||||
|
let old = pageControllers[currentIndex]
|
||||||
|
|
||||||
|
new.loadViewIfNeeded()
|
||||||
|
|
||||||
|
self.currentIndex = index
|
||||||
|
|
||||||
// TODO: old.headerCell could be nil if scrolled down and key command used
|
// TODO: old.headerCell could be nil if scrolled down and key command used
|
||||||
let oldHeaderCell = old.headerCell!
|
let oldHeaderCell = old.headerCell!
|
||||||
|
@ -173,8 +179,8 @@ class ProfileViewController: UIPageViewController {
|
||||||
// old header cell must have the header view
|
// old header cell must have the header view
|
||||||
let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)!
|
let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)!
|
||||||
|
|
||||||
if new.isViewLoaded {
|
if let newHeaderCell = new.headerCell {
|
||||||
_ = new.headerCell!.addConstraint(height: oldHeaderCell.bounds.height)
|
_ = newHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)
|
||||||
} else {
|
} else {
|
||||||
new.initialHeaderMode = .placeholder(height: oldHeaderCell.bounds.height)
|
new.initialHeaderMode = .placeholder(height: oldHeaderCell.bounds.height)
|
||||||
}
|
}
|
||||||
|
@ -195,60 +201,66 @@ class ProfileViewController: UIPageViewController {
|
||||||
// hide scroll indicators during the transition because otherwise the show through the
|
// hide scroll indicators during the transition because otherwise the show through the
|
||||||
// profile header, even though it has an opaque background
|
// profile header, even though it has an opaque background
|
||||||
old.collectionView.showsVerticalScrollIndicator = false
|
old.collectionView.showsVerticalScrollIndicator = false
|
||||||
if new.isViewLoaded {
|
new.collectionView.showsVerticalScrollIndicator = false
|
||||||
new.collectionView.showsVerticalScrollIndicator = false
|
|
||||||
|
let origOldContentOffset = old.collectionView.contentOffset
|
||||||
|
// we can't just change the content offset during the animation, otherwise the new collection view doesn't size the cells at the top
|
||||||
|
// and new's offset doesn't physically match old's, even though they're numerically the same
|
||||||
|
let needsMatchContentOffsetWithTransform = new.state != .loaded
|
||||||
|
let yTranslationToMatchOldContentOffset: CGFloat
|
||||||
|
if needsMatchContentOffsetWithTransform {
|
||||||
|
yTranslationToMatchOldContentOffset = -origOldContentOffset.y - view.safeAreaInsets.top
|
||||||
|
} else {
|
||||||
|
new.collectionView.contentOffset = origOldContentOffset
|
||||||
|
yTranslationToMatchOldContentOffset = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the new view isn't loaded or it isn't tall enough to match content offsets, animate scrolling old back to top to match new
|
if animated {
|
||||||
if animated,
|
// if the new view isn't tall enough to match content offsets
|
||||||
!new.isViewLoaded || new.collectionView.contentSize.height - new.collectionView.bounds.height < old.collectionView.contentOffset.y {
|
if new.collectionView.contentSize.height - new.collectionView.bounds.height < old.collectionView.contentOffset.y {
|
||||||
// We need to display a snapshot over the old view because setting the content offset to the top w/o animating
|
let additionalHeightNeededToMatchContentOffset = old.collectionView.contentOffset.y + old.collectionView.bounds.height - new.collectionView.contentSize.height
|
||||||
// results in the collection view immediately removing cells that will be offscreen.
|
new.collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: additionalHeightNeededToMatchContentOffset, right: 0)
|
||||||
// And we can't just call setContentOffset(_:animated:) because its animation curve does not match ours/the page views
|
|
||||||
// So, we capture a snapshot before the content offset is changed, so those cells can be shown during the animation,
|
|
||||||
// rather than a gap appearing during it.
|
|
||||||
let snapshot = old.collectionView.snapshotView(afterScreenUpdates: true)!
|
|
||||||
let origOldContentOffset = old.collectionView.contentOffset
|
|
||||||
old.collectionView.contentOffset = CGPoint(x: 0, y: view.safeAreaInsets.top)
|
|
||||||
|
|
||||||
snapshot.frame = old.collectionView.bounds
|
|
||||||
snapshot.frame.origin.y = 0
|
|
||||||
snapshot.layer.zPosition = 99
|
|
||||||
view.addSubview(snapshot)
|
|
||||||
|
|
||||||
// empirically, 0.3s seems to match the UIPageViewController animation
|
|
||||||
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) {
|
|
||||||
// animate the snapshot offscreen in the same direction as the old view
|
|
||||||
snapshot.frame.origin.x = direction == .forward ? -self.view.bounds.width : self.view.bounds.width
|
|
||||||
// animate the snapshot to be "scrolled" to top
|
|
||||||
snapshot.frame.origin.y = self.view.safeAreaInsets.top + origOldContentOffset.y
|
|
||||||
// if scrolling because the new collection view's content isn't tall enough, make sure to scroll it to top as well
|
|
||||||
if new.isViewLoaded {
|
|
||||||
new.collectionView.contentOffset = CGPoint(x: 0, y: -self.view.safeAreaInsets.top)
|
|
||||||
}
|
|
||||||
headerView.transform = CGAffineTransform(translationX: 0, y: -headerTopOffset)
|
|
||||||
} completion: { _ in
|
|
||||||
snapshot.removeFromSuperview()
|
|
||||||
}
|
}
|
||||||
} else if new.isViewLoaded {
|
|
||||||
new.collectionView.contentOffset = old.collectionView.contentOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in
|
new.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
// reenable scroll indicators after the switching animation is done
|
embedChild(new)
|
||||||
old.collectionView.showsVerticalScrollIndicator = true
|
new.view.transform = CGAffineTransform(translationX: direction * view.bounds.width, y: yTranslationToMatchOldContentOffset)
|
||||||
new.collectionView.showsVerticalScrollIndicator = true
|
|
||||||
|
|
||||||
headerView.isUserInteractionEnabled = true
|
let animator = UIViewPropertyAnimator(duration: 0.5, timingParameters: UISpringTimingParameters(dampingRatio: 1, initialVelocity: .zero))
|
||||||
|
animator.addAnimations {
|
||||||
|
new.view.transform = CGAffineTransform(translationX: 0, y: yTranslationToMatchOldContentOffset)
|
||||||
|
old.view.transform = CGAffineTransform(translationX: -direction * self.view.bounds.width, y: 0)
|
||||||
|
}
|
||||||
|
animator.addCompletion { _ in
|
||||||
|
old.removeViewAndController()
|
||||||
|
old.collectionView.transform = .identity
|
||||||
|
|
||||||
headerView.transform = .identity
|
new.collectionView.transform = .identity
|
||||||
headerView.layer.zPosition = 0
|
new.collectionView.contentOffset = origOldContentOffset
|
||||||
// move the header view into the new page controller's cell
|
|
||||||
// new's headerCell should always be non-nil, because the account must be loaded (in order to have triggered this switch), and so new should add the cell immediately on load
|
|
||||||
new.headerCell!.addHeader(headerView)
|
|
||||||
|
|
||||||
self.state = .idle
|
// reenable scroll indicators after the switching animation is done
|
||||||
completion?(finished)
|
old.collectionView.showsVerticalScrollIndicator = true
|
||||||
|
new.collectionView.showsVerticalScrollIndicator = true
|
||||||
|
|
||||||
|
headerView.isUserInteractionEnabled = true
|
||||||
|
headerView.transform = .identity
|
||||||
|
headerView.layer.zPosition = 0
|
||||||
|
// move the header view into the new page controller's cell
|
||||||
|
if let newHeaderCell = new.headerCell {
|
||||||
|
newHeaderCell.addHeader(headerView)
|
||||||
|
} else {
|
||||||
|
new.initialHeaderMode = .useExistingView(headerView)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.state = .idle
|
||||||
|
completion?(true)
|
||||||
|
}
|
||||||
|
animator.startAnimation()
|
||||||
|
} else {
|
||||||
|
old.removeViewAndController()
|
||||||
|
new.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
embedChild(new)
|
||||||
|
completion?(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -197,7 +197,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
|
|
||||||
@objc func refresh() {
|
@objc func refresh() {
|
||||||
Task {
|
Task {
|
||||||
await controller.loadNewer()
|
if case .notLoadedInitial = await controller.state {
|
||||||
|
await controller.loadInitial()
|
||||||
|
} else {
|
||||||
|
await controller.loadNewer()
|
||||||
|
}
|
||||||
#if !targetEnvironment(macCatalyst)
|
#if !targetEnvironment(macCatalyst)
|
||||||
collectionView.refreshControl?.endRefreshing()
|
collectionView.refreshControl?.endRefreshing()
|
||||||
#endif
|
#endif
|
||||||
|
@ -280,6 +284,8 @@ extension TimelineViewController {
|
||||||
typealias TimelineItem = String // status ID
|
typealias TimelineItem = String // status ID
|
||||||
|
|
||||||
func loadInitial() async throws -> [TimelineItem] {
|
func loadInitial() async throws -> [TimelineItem] {
|
||||||
|
try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
|
||||||
|
|
||||||
guard let mastodonController else {
|
guard let mastodonController else {
|
||||||
throw Error.noClient
|
throw Error.noClient
|
||||||
}
|
}
|
||||||
|
|
|
@ -76,6 +76,7 @@ class CustomAlertController: UIViewController {
|
||||||
let titleLabel = UILabel()
|
let titleLabel = UILabel()
|
||||||
titleLabel.text = config.title
|
titleLabel.text = config.title
|
||||||
titleLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0)
|
titleLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0)
|
||||||
|
titleLabel.adjustsFontForContentSizeCategory = true
|
||||||
titleLabel.numberOfLines = 0
|
titleLabel.numberOfLines = 0
|
||||||
titleLabel.textAlignment = .center
|
titleLabel.textAlignment = .center
|
||||||
stack.addArrangedSubview(titleLabel)
|
stack.addArrangedSubview(titleLabel)
|
||||||
|
@ -361,13 +362,14 @@ class CustomAlertActionButton: UIControl {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.text = title
|
label.text = title
|
||||||
label.textColor = .tintColor
|
label.textColor = .tintColor
|
||||||
switch action.style {
|
label.adjustsFontForContentSizeCategory = true
|
||||||
case .cancel:
|
if case .cancel = action.style {
|
||||||
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0)
|
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0)
|
||||||
case .destructive:
|
} else {
|
||||||
|
label.font = .preferredFont(forTextStyle: .body)
|
||||||
|
}
|
||||||
|
if case .destructive = action.style {
|
||||||
label.textColor = .systemRed
|
label.textColor = .systemRed
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
titleView.addArrangedSubview(label)
|
titleView.addArrangedSubview(label)
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import UIKit
|
||||||
import SafariServices
|
import SafariServices
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import WebURLFoundationExtras
|
import WebURLFoundationExtras
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
protocol MenuActionProvider: AnyObject {
|
protocol MenuActionProvider: AnyObject {
|
||||||
var navigationDelegate: TuskerNavigationDelegate? { get }
|
var navigationDelegate: TuskerNavigationDelegate? { get }
|
||||||
|
@ -42,46 +43,6 @@ extension MenuActionProvider {
|
||||||
guard let mastodonController = mastodonController,
|
guard let mastodonController = mastodonController,
|
||||||
let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
|
let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
|
||||||
|
|
||||||
guard let loggedInAccountID = mastodonController.accountInfo?.id else {
|
|
||||||
// user is logged out
|
|
||||||
return [
|
|
||||||
openInSafariAction(url: account.url),
|
|
||||||
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView)
|
|
||||||
})
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
let actionsSection: [UIMenuElement] = [
|
|
||||||
createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { [weak self] (_) in
|
|
||||||
guard let self = self else { return }
|
|
||||||
let draft = self.mastodonController!.createDraft(mentioningAcct: account.acct)
|
|
||||||
draft.visibility = .direct
|
|
||||||
self.navigationDelegate?.compose(editing: draft)
|
|
||||||
}),
|
|
||||||
UIDeferredMenuElement.uncached({ @MainActor [unowned self] elementHandler in
|
|
||||||
let relationship = Task {
|
|
||||||
await fetchRelationship(accountID: accountID, mastodonController: mastodonController)
|
|
||||||
}
|
|
||||||
// workaround for #198, may result in showing outdated relationship, so only do so where necessary
|
|
||||||
if ProcessInfo.processInfo.isiOSAppOnMac,
|
|
||||||
let mo = mastodonController.persistentContainer.relationship(forAccount: accountID),
|
|
||||||
let action = self.followAction(for: mo, mastodonController: mastodonController) {
|
|
||||||
elementHandler([action])
|
|
||||||
} else {
|
|
||||||
Task { @MainActor in
|
|
||||||
if let relationship = await relationship.value,
|
|
||||||
let action = self.followAction(for: relationship, mastodonController: mastodonController) {
|
|
||||||
elementHandler([action])
|
|
||||||
} else {
|
|
||||||
elementHandler([])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]
|
|
||||||
|
|
||||||
var shareSection = [
|
var shareSection = [
|
||||||
openInSafariAction(url: account.url),
|
openInSafariAction(url: account.url),
|
||||||
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
|
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
|
||||||
|
@ -90,11 +51,33 @@ extension MenuActionProvider {
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|
||||||
|
guard let loggedInAccountID = mastodonController.accountInfo?.id else {
|
||||||
|
// user is logged out
|
||||||
|
return shareSection
|
||||||
|
}
|
||||||
|
|
||||||
|
var actionsSection: [UIMenuElement] = [
|
||||||
|
createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { [weak self] (_) in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let draft = self.mastodonController!.createDraft(mentioningAcct: account.acct)
|
||||||
|
draft.visibility = .direct
|
||||||
|
self.navigationDelegate?.compose(editing: draft)
|
||||||
|
})
|
||||||
|
]
|
||||||
|
var suppressSection: [UIMenuElement] = []
|
||||||
|
|
||||||
|
if accountID != loggedInAccountID {
|
||||||
|
actionsSection.append(relationshipAction(accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.followAction(for: $0, mastodonController: $1) }))
|
||||||
|
suppressSection.append(relationshipAction(accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.blockAction(for: $0, mastodonController: $1) }))
|
||||||
|
suppressSection.append(relationshipAction(accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.muteAction(for: $0, mastodonController: $1) }))
|
||||||
|
}
|
||||||
|
|
||||||
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showProfileActivity(id: accountID, accountID: loggedInAccountID))
|
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showProfileActivity(id: accountID, accountID: loggedInAccountID))
|
||||||
|
|
||||||
return [
|
return [
|
||||||
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
|
UIMenu(options: .displayInline, children: shareSection),
|
||||||
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: actionsSection),
|
UIMenu(options: .displayInline, children: actionsSection),
|
||||||
|
UIMenu(options: .displayInline, children: suppressSection),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -166,12 +149,7 @@ extension MenuActionProvider {
|
||||||
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
|
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
|
||||||
|
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
if let toastable = self.toastableViewController {
|
self.handleError(error, title: "Error \(bookmarked ? "Unb" : "B")ookmarking")
|
||||||
let config = ToastConfiguration(from: error, with: "Error \(bookmarked ? "Unb" : "B")ookmarking", in: toastable, retryAction: nil)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
toastable.showToast(configuration: config, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
@ -227,12 +205,7 @@ extension MenuActionProvider {
|
||||||
case .success(let status, _):
|
case .success(let status, _):
|
||||||
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
|
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
if let toastable = self.toastableViewController {
|
self.handleError(error, title: "Error \(muted ? "Unm" : "M")uting")
|
||||||
let config = ToastConfiguration(from: error, with: "Error \(muted ? "Unm" : "M")uting", in: toastable, retryAction: nil)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
toastable.showToast(configuration: config, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
@ -251,12 +224,7 @@ extension MenuActionProvider {
|
||||||
case .success(let status, _):
|
case .success(let status, _):
|
||||||
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
|
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
if let toastable = self.toastableViewController {
|
self.handleError(error, title: "Error \(pinned ? "Unp" :"P")inning")
|
||||||
let config = ToastConfiguration(from: error, with: "Error \(pinned ? "Unp" :"P")inning", in: toastable, retryAction: nil)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
toastable.showToast(configuration: config, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
@ -276,12 +244,7 @@ extension MenuActionProvider {
|
||||||
mastodonController.persistentContainer.addOrUpdate(status: status, context: mastodonController.persistentContainer.viewContext)
|
mastodonController.persistentContainer.addOrUpdate(status: status, context: mastodonController.persistentContainer.viewContext)
|
||||||
}
|
}
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
if let toastable = self?.toastableViewController {
|
self?.handleError(error, title: "Error Refreshing Poll")
|
||||||
let config = ToastConfiguration(from: error, with: "Error Refreshing Poll", in: toastable, retryAction: nil)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
toastable.showToast(configuration: config, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}), at: 0)
|
}), at: 0)
|
||||||
|
@ -367,25 +330,46 @@ extension MenuActionProvider {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
private func handleError(_ error: Client.Error, title: String) {
|
||||||
private func followAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement? {
|
if let toastable = self.toastableViewController {
|
||||||
guard let ownAccount = mastodonController.account,
|
let config = ToastConfiguration(from: error, with: title, in: toastable, retryAction: nil)
|
||||||
relationship.accountID != ownAccount.id else {
|
DispatchQueue.main.async {
|
||||||
return nil
|
toastable.showToast(configuration: config, animated: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func relationshipAction(accountID: String, mastodonController: MastodonController, builder: @escaping @MainActor (RelationshipMO, MastodonController) -> UIMenuElement) -> UIDeferredMenuElement {
|
||||||
|
return UIDeferredMenuElement.uncached({ @MainActor elementHandler in
|
||||||
|
let relationship = Task {
|
||||||
|
await fetchRelationship(accountID: accountID, mastodonController: mastodonController)
|
||||||
|
}
|
||||||
|
// workaround for #198, may result in showing outdated relationship, so only do so where necessary
|
||||||
|
if ProcessInfo.processInfo.isiOSAppOnMac,
|
||||||
|
let mo = mastodonController.persistentContainer.relationship(forAccount: accountID) {
|
||||||
|
elementHandler([builder(mo, mastodonController)])
|
||||||
|
} else {
|
||||||
|
Task { @MainActor in
|
||||||
|
if let relationship = await relationship.value {
|
||||||
|
elementHandler([builder(relationship, mastodonController)])
|
||||||
|
} else {
|
||||||
|
elementHandler([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func followAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement {
|
||||||
let accountID = relationship.accountID
|
let accountID = relationship.accountID
|
||||||
let following = relationship.following
|
let following = relationship.following
|
||||||
return createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus") { _ in
|
return createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus") { [weak self] _ in
|
||||||
let request = (following ? Account.unfollow : Account.follow)(accountID)
|
let request = (following ? Account.unfollow : Account.follow)(accountID)
|
||||||
mastodonController.run(request) { response in
|
mastodonController.run(request) { response in
|
||||||
switch response {
|
switch response {
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
if let toastable = self.toastableViewController {
|
self?.handleError(error, title: "Error \(following ? "Unf" : "F")ollowing")
|
||||||
let config = ToastConfiguration(from: error, with: "Error \(following ? "Unf" : "F")ollowing", in: toastable, retryAction: nil)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
toastable.showToast(configuration: config, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .success(let relationship, _):
|
case .success(let relationship, _):
|
||||||
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
||||||
}
|
}
|
||||||
|
@ -393,6 +377,71 @@ extension MenuActionProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func blockAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement {
|
||||||
|
let accountID = relationship.accountID
|
||||||
|
let displayName = relationship.account!.displayOrUserName
|
||||||
|
let host = relationship.account!.url.host!
|
||||||
|
let handler = { (block: Bool) in
|
||||||
|
return { [weak self] (_: UIAction) in
|
||||||
|
let req = block ? Account.block(accountID) : Account.unblock(accountID)
|
||||||
|
_ = mastodonController.run(req) { response in
|
||||||
|
switch response {
|
||||||
|
case .failure(let error):
|
||||||
|
self?.handleError(error, title: "Error \(block ? "B" : "Unb")locking")
|
||||||
|
case .success(let relationship, _):
|
||||||
|
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let domainHandler = { (block: Bool) in
|
||||||
|
return { [weak self] (_: UIAction) in
|
||||||
|
let req = block ? Client.block(domain: host) : Client.unblock(domain: host)
|
||||||
|
mastodonController.run(req) { response in
|
||||||
|
if case .failure(let error) = response {
|
||||||
|
self?.handleError(error, title: "Error \(block ? "B" : "Unb")locking")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if relationship.domainBlocking {
|
||||||
|
return createAction(identifier: "block", title: "Unblock \(host)", systemImageName: "circle.slash", handler: domainHandler(false))
|
||||||
|
} else if relationship.blocking {
|
||||||
|
return createAction(identifier: "block", title: "Unblock \(displayName)", systemImageName: "circle.slash", handler: handler(false))
|
||||||
|
} else {
|
||||||
|
let image = UIImage(systemName: "circle.slash")
|
||||||
|
return UIMenu(title: "Block", image: image, children: [
|
||||||
|
UIAction(title: "Cancel", handler: { _ in }),
|
||||||
|
UIAction(title: "Block \(displayName)", image: image, attributes: .destructive, handler: handler(true)),
|
||||||
|
UIAction(title: "Block \(host)", image: image, attributes: .destructive, handler: domainHandler(true))
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func muteAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement {
|
||||||
|
if relationship.muting || relationship.mutingNotifications {
|
||||||
|
return UIAction(title: "Unmute", image: UIImage(systemName: "speaker")) { [weak self] _ in
|
||||||
|
let req = Account.unmute(relationship.accountID)
|
||||||
|
mastodonController.run(req) { response in
|
||||||
|
switch response {
|
||||||
|
case .failure(let error):
|
||||||
|
self?.handleError(error, title: "Error Unmuting")
|
||||||
|
case .success(let relationship, _):
|
||||||
|
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return UIAction(title: "Mute", image: UIImage(systemName: "speaker.slash")) { [weak self] _ in
|
||||||
|
let view = MuteAccountView(account: relationship.account!, mastodonController: mastodonController)
|
||||||
|
let host = UIHostingController(rootView: view)
|
||||||
|
self?.navigationDelegate?.present(host, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchRelationship(accountID: String, mastodonController: MastodonController) async -> RelationshipMO? {
|
private func fetchRelationship(accountID: String, mastodonController: MastodonController) async -> RelationshipMO? {
|
||||||
|
|
|
@ -64,6 +64,14 @@ extension TimelineLikeCollectionViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleAddLoadingIndicator() async {
|
func handleAddLoadingIndicator() async {
|
||||||
|
if case .loadingInitial(_, _) = await controller.state,
|
||||||
|
let refreshControl = collectionView.refreshControl,
|
||||||
|
refreshControl.isRefreshing {
|
||||||
|
refreshControl.beginRefreshing()
|
||||||
|
// if we're loading initial and the refresh control is already going, we don't need to add another indicator
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
if !snapshot.sectionIdentifiers.contains(.footer) {
|
if !snapshot.sectionIdentifiers.contains(.footer) {
|
||||||
snapshot.appendSections([.footer])
|
snapshot.appendSections([.footer])
|
||||||
|
@ -77,6 +85,13 @@ extension TimelineLikeCollectionViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleRemoveLoadingIndicator() async {
|
func handleRemoveLoadingIndicator() async {
|
||||||
|
if case .loadingInitial(_, _) = await controller.state,
|
||||||
|
let refreshControl = collectionView.refreshControl,
|
||||||
|
refreshControl.isRefreshing {
|
||||||
|
refreshControl.endRefreshing()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let oldContentOffset = collectionView.contentOffset
|
let oldContentOffset = collectionView.contentOffset
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
snapshot.deleteSections([.footer])
|
snapshot.deleteSections([.footer])
|
||||||
|
|
|
@ -70,7 +70,7 @@ actor TimelineLikeController<Item> {
|
||||||
} catch {
|
} catch {
|
||||||
await loadingIndicator.end()
|
await loadingIndicator.end()
|
||||||
await emit(event: .loadAllError(error, token))
|
await emit(event: .loadAllError(error, token))
|
||||||
state = .idle
|
state = .notLoadedInitial
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -194,7 +194,7 @@ actor TimelineLikeController<Item> {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
||||||
return to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true))
|
return to == .notLoadedInitial || to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true))
|
||||||
case .loadingNewer(_):
|
case .loadingNewer(_):
|
||||||
return to == .idle
|
return to == .idle
|
||||||
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
||||||
|
|
|
@ -96,9 +96,15 @@ extension TuskerNavigationDelegate {
|
||||||
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil)
|
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil)
|
||||||
} else {
|
} else {
|
||||||
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
||||||
let nav = UINavigationController(rootViewController: compose)
|
if #available(iOS 16.0, *),
|
||||||
nav.presentationController?.delegate = compose
|
presentDuckable(compose) {
|
||||||
present(nav, animated: true)
|
return
|
||||||
|
} else {
|
||||||
|
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
||||||
|
let nav = UINavigationController(rootViewController: compose)
|
||||||
|
nav.presentationController?.delegate = compose
|
||||||
|
present(nav, animated: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,11 +11,9 @@ import Foundation
|
||||||
struct ViewTags {
|
struct ViewTags {
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
static let composeVisibilityBarButton = 42001
|
static let navBackBarButton = 42001
|
||||||
static let composeLocalOnlyBarButton = 42002
|
static let navForwardBarButton = 42002
|
||||||
static let navBackBarButton = 42003
|
static let navEmptyTitleView = 42003
|
||||||
static let navForwardBarButton = 42004
|
static let splitNavCloseSecondaryButton = 42004
|
||||||
static let navEmptyTitleView = 42005
|
static let customAlertSeparator = 42005
|
||||||
static let splitNavCloseSecondaryButton = 42006
|
|
||||||
static let customAlertSeparator = 42007
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,6 +69,7 @@ class AccountTableViewCell: UITableViewCell {
|
||||||
|
|
||||||
let accountID = self.accountID
|
let accountID = self.accountID
|
||||||
|
|
||||||
|
avatarImageView.image = nil
|
||||||
if let avatarURL = account.avatar {
|
if let avatarURL = account.avatar {
|
||||||
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
|
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
//
|
||||||
|
// AlertWithData.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/9/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AlertWithData<Data, A: View, M: View>: ViewModifier {
|
||||||
|
let title: LocalizedStringKey
|
||||||
|
@Binding var data: Data?
|
||||||
|
let actions: (Data) -> A
|
||||||
|
let message: (Data) -> M
|
||||||
|
|
||||||
|
private var isPresented: Binding<Bool> {
|
||||||
|
Binding(get: {
|
||||||
|
data != nil
|
||||||
|
}, set: { newValue in
|
||||||
|
guard !newValue else {
|
||||||
|
fatalError("Cannot set isPresented to true without data")
|
||||||
|
}
|
||||||
|
data = nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
init(title: LocalizedStringKey, data: Binding<Data?>, @ViewBuilder actions: @escaping (Data) -> A, @ViewBuilder message: @escaping (Data) -> M) {
|
||||||
|
self.title = title
|
||||||
|
self._data = data
|
||||||
|
self.actions = actions
|
||||||
|
self.message = message
|
||||||
|
}
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.alert(title, isPresented: isPresented, presenting: data, actions: actions, message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func alertWithData<Data, A: View, M: View>(_ title: LocalizedStringKey, data: Binding<Data?>, @ViewBuilder actions: @escaping (Data) -> A, @ViewBuilder message: @escaping (Data) -> M) -> some View {
|
||||||
|
modifier(AlertWithData(title: title, data: data, actions: actions, message: message))
|
||||||
|
}
|
||||||
|
}
|
|
@ -55,6 +55,7 @@ class ConfirmReblogStatusPreviewView: UIView {
|
||||||
let displayNameLabel = EmojiLabel()
|
let displayNameLabel = EmojiLabel()
|
||||||
displayNameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .caption1).addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold]]), size: 0)
|
displayNameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .caption1).addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold]]), size: 0)
|
||||||
displayNameLabel.adjustsFontSizeToFitWidth = true
|
displayNameLabel.adjustsFontSizeToFitWidth = true
|
||||||
|
displayNameLabel.adjustsFontForContentSizeCategory = true
|
||||||
displayNameLabel.updateForAccountDisplayName(account: status.account)
|
displayNameLabel.updateForAccountDisplayName(account: status.account)
|
||||||
vStack.addArrangedSubview(displayNameLabel)
|
vStack.addArrangedSubview(displayNameLabel)
|
||||||
|
|
||||||
|
@ -64,6 +65,7 @@ class ConfirmReblogStatusPreviewView: UIView {
|
||||||
contentView.isScrollEnabled = false
|
contentView.isScrollEnabled = false
|
||||||
contentView.backgroundColor = nil
|
contentView.backgroundColor = nil
|
||||||
contentView.textContainerInset = .zero
|
contentView.textContainerInset = .zero
|
||||||
|
contentView.adjustsFontForContentSizeCategory = true
|
||||||
// remove the extra line spacing applied by StatusContentTextView because, since we're using a smaller font, the regular 2pt looks big
|
// remove the extra line spacing applied by StatusContentTextView because, since we're using a smaller font, the regular 2pt looks big
|
||||||
contentView.paragraphStyle = .default
|
contentView.paragraphStyle = .default
|
||||||
// TODO: line limit
|
// TODO: line limit
|
||||||
|
|
|
@ -1,71 +0,0 @@
|
||||||
//
|
|
||||||
// DraftsTableViewCell.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 10/22/18.
|
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import Photos
|
|
||||||
|
|
||||||
class DraftTableViewCell: UITableViewCell {
|
|
||||||
|
|
||||||
@IBOutlet weak var contentWarningLabel: UILabel!
|
|
||||||
@IBOutlet weak var contentLabel: UILabel!
|
|
||||||
@IBOutlet weak var lastModifiedLabel: UILabel!
|
|
||||||
@IBOutlet weak var attachmentsStackViewContainer: UIView!
|
|
||||||
@IBOutlet weak var attachmentsStackView: UIStackView!
|
|
||||||
|
|
||||||
override func awakeFromNib() {
|
|
||||||
super.awakeFromNib()
|
|
||||||
|
|
||||||
contentWarningLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .bold))
|
|
||||||
contentWarningLabel.adjustsFontForContentSizeCategory = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUI(for draft: Draft) {
|
|
||||||
contentWarningLabel.text = draft.contentWarning
|
|
||||||
contentWarningLabel.isHidden = !draft.contentWarningEnabled
|
|
||||||
contentLabel.text = draft.text
|
|
||||||
lastModifiedLabel.text = draft.lastModified.timeAgoString()
|
|
||||||
|
|
||||||
attachmentsStackViewContainer.isHidden = draft.attachments.count == 0
|
|
||||||
|
|
||||||
for attachment in draft.attachments {
|
|
||||||
let size = CGSize(width: 50, height: 50)
|
|
||||||
let imageView = UIImageView(frame: CGRect(origin: .zero, size: size))
|
|
||||||
imageView.contentMode = .scaleAspectFill
|
|
||||||
imageView.layer.masksToBounds = true
|
|
||||||
imageView.layer.cornerRadius = 5
|
|
||||||
attachmentsStackView.addArrangedSubview(imageView)
|
|
||||||
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor).isActive = true
|
|
||||||
|
|
||||||
imageView.backgroundColor = .secondarySystemBackground
|
|
||||||
imageView.contentMode = .scaleAspectFill
|
|
||||||
|
|
||||||
switch attachment.data {
|
|
||||||
case let .asset(asset):
|
|
||||||
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
|
|
||||||
imageView.image = image
|
|
||||||
}
|
|
||||||
case let .image(image):
|
|
||||||
imageView.image = image
|
|
||||||
case .video(_):
|
|
||||||
// videos aren't saved to drafts, so this is unreachable
|
|
||||||
return
|
|
||||||
case let .drawing(drawing):
|
|
||||||
imageView.image = drawing.imageInLightMode(from: drawing.bounds)
|
|
||||||
imageView.backgroundColor = .white
|
|
||||||
imageView.contentMode = .scaleAspectFit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func prepareForReuse() {
|
|
||||||
super.prepareForReuse()
|
|
||||||
|
|
||||||
attachmentsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,98 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
|
||||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
|
||||||
<dependencies>
|
|
||||||
<deployment identifier="iOS"/>
|
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
|
|
||||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
|
||||||
</dependencies>
|
|
||||||
<objects>
|
|
||||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
|
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="143" id="Q7N-Mt-RPF" customClass="DraftTableViewCell" customModule="Tusker" customModuleProvider="target">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="143"/>
|
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
|
||||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Q7N-Mt-RPF" id="KVi-jA-AET">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="143"/>
|
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
|
||||||
<subviews>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="gaD-3B-qO1">
|
|
||||||
<rect key="frame" x="16" y="11" width="351" height="124"/>
|
|
||||||
<subviews>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Content Warning" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="VhS-ig-6Fu">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="351" height="18"/>
|
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="15"/>
|
|
||||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="zMS-88-DcM">
|
|
||||||
<rect key="frame" x="0.0" y="26" width="351" height="40"/>
|
|
||||||
<subviews>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" ambiguous="YES" text="Content" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="3" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="8eA-yd-rBp">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="310.5" height="32"/>
|
|
||||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
|
||||||
<nil key="textColor"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="D2X-9O-iQw">
|
|
||||||
<rect key="frame" x="326.5" y="0.0" width="24.5" height="20.5"/>
|
|
||||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
|
||||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
</subviews>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstItem="D2X-9O-iQw" firstAttribute="leading" secondItem="8eA-yd-rBp" secondAttribute="trailing" constant="16" id="6Ux-ee-J5h"/>
|
|
||||||
<constraint firstAttribute="trailing" secondItem="D2X-9O-iQw" secondAttribute="trailing" id="IRH-mM-HSs"/>
|
|
||||||
<constraint firstItem="8eA-yd-rBp" firstAttribute="leading" secondItem="zMS-88-DcM" secondAttribute="leading" id="StS-F9-9B3"/>
|
|
||||||
<constraint firstItem="8eA-yd-rBp" firstAttribute="top" secondItem="zMS-88-DcM" secondAttribute="top" id="Uuq-g5-n0A"/>
|
|
||||||
<constraint firstItem="D2X-9O-iQw" firstAttribute="top" secondItem="zMS-88-DcM" secondAttribute="top" id="lWB-6Z-nbG"/>
|
|
||||||
<constraint firstAttribute="bottom" secondItem="8eA-yd-rBp" secondAttribute="bottom" id="zCK-s5-4Zo"/>
|
|
||||||
</constraints>
|
|
||||||
</view>
|
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="csc-gx-KVg">
|
|
||||||
<rect key="frame" x="0.0" y="74" width="351" height="50"/>
|
|
||||||
<subviews>
|
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" ambiguous="YES" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="htC-hf-vJ4">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="352" height="50"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="height" constant="50" id="lxT-O2-afE"/>
|
|
||||||
</constraints>
|
|
||||||
</stackView>
|
|
||||||
</subviews>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstItem="htC-hf-vJ4" firstAttribute="leading" secondItem="csc-gx-KVg" secondAttribute="leading" id="c0s-O9-XKa"/>
|
|
||||||
<constraint firstItem="htC-hf-vJ4" firstAttribute="top" secondItem="csc-gx-KVg" secondAttribute="top" id="lcl-RN-qHw"/>
|
|
||||||
<constraint firstAttribute="bottom" secondItem="htC-hf-vJ4" secondAttribute="bottom" id="oHX-Qh-bmI"/>
|
|
||||||
</constraints>
|
|
||||||
</view>
|
|
||||||
</subviews>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="trailing" secondItem="csc-gx-KVg" secondAttribute="trailing" id="AcZ-yc-8Zh"/>
|
|
||||||
</constraints>
|
|
||||||
</stackView>
|
|
||||||
</subviews>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="bottom" secondItem="gaD-3B-qO1" secondAttribute="bottomMargin" constant="8" id="4Hz-ax-JI6"/>
|
|
||||||
<constraint firstItem="gaD-3B-qO1" firstAttribute="leading" secondItem="KVi-jA-AET" secondAttribute="leadingMargin" id="KRA-Q8-klX"/>
|
|
||||||
<constraint firstAttribute="trailing" secondItem="gaD-3B-qO1" secondAttribute="trailingMargin" constant="8" id="iGc-c4-n9y"/>
|
|
||||||
<constraint firstItem="gaD-3B-qO1" firstAttribute="top" secondItem="KVi-jA-AET" secondAttribute="topMargin" id="rVE-Jo-6zG"/>
|
|
||||||
</constraints>
|
|
||||||
</tableViewCellContentView>
|
|
||||||
<connections>
|
|
||||||
<outlet property="attachmentsStackView" destination="htC-hf-vJ4" id="kEX-m7-LuE"/>
|
|
||||||
<outlet property="attachmentsStackViewContainer" destination="csc-gx-KVg" id="rIM-pj-TFX"/>
|
|
||||||
<outlet property="contentLabel" destination="8eA-yd-rBp" id="Uy0-8G-WbU"/>
|
|
||||||
<outlet property="contentWarningLabel" destination="VhS-ig-6Fu" id="jIU-vr-OsY"/>
|
|
||||||
<outlet property="lastModifiedLabel" destination="D2X-9O-iQw" id="dx7-0E-RuM"/>
|
|
||||||
</connections>
|
|
||||||
<point key="canvasLocation" x="-388" y="184.85757121439281"/>
|
|
||||||
</tableViewCell>
|
|
||||||
</objects>
|
|
||||||
<resources>
|
|
||||||
<systemColor name="secondaryLabelColor">
|
|
||||||
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
|
|
||||||
</systemColor>
|
|
||||||
</resources>
|
|
||||||
</document>
|
|
|
@ -0,0 +1,98 @@
|
||||||
|
//
|
||||||
|
// MenuPicker.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/7/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MenuPicker<Value: Hashable>: UIViewRepresentable {
|
||||||
|
typealias UIViewType = UIButton
|
||||||
|
|
||||||
|
@Binding var selection: Value
|
||||||
|
let options: [Option]
|
||||||
|
var buttonStyle: ButtonStyle = .labelAndIcon
|
||||||
|
|
||||||
|
private var selectedOption: Option {
|
||||||
|
options.first(where: { $0.value == selection })!
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> UIButton {
|
||||||
|
let button = UIButton()
|
||||||
|
button.showsMenuAsPrimaryAction = true
|
||||||
|
button.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ button: UIButton, context: Context) {
|
||||||
|
var config = UIButton.Configuration.borderless()
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
config.indicator = .popup
|
||||||
|
}
|
||||||
|
if buttonStyle.hasIcon {
|
||||||
|
config.image = selectedOption.image
|
||||||
|
}
|
||||||
|
if buttonStyle.hasLabel {
|
||||||
|
config.title = selectedOption.title
|
||||||
|
}
|
||||||
|
button.configuration = config
|
||||||
|
button.menu = UIMenu(children: options.map { opt in
|
||||||
|
UIAction(title: opt.title, subtitle: opt.subtitle, image: opt.image, state: opt.value == selection ? .on : .off) { _ in
|
||||||
|
selection = opt.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
button.accessibilityLabel = selectedOption.accessibilityLabel ?? selectedOption.title
|
||||||
|
button.isPointerInteractionEnabled = buttonStyle == .iconOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Option {
|
||||||
|
let value: Value
|
||||||
|
let title: String
|
||||||
|
let subtitle: String?
|
||||||
|
let image: UIImage?
|
||||||
|
let accessibilityLabel: String?
|
||||||
|
|
||||||
|
init(value: Value, title: String, subtitle: String? = nil, image: UIImage? = nil, accessibilityLabel: String? = nil) {
|
||||||
|
self.value = value
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.image = image
|
||||||
|
self.accessibilityLabel = accessibilityLabel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ButtonStyle {
|
||||||
|
case labelAndIcon, labelOnly, iconOnly
|
||||||
|
|
||||||
|
var hasLabel: Bool {
|
||||||
|
switch self {
|
||||||
|
case .labelAndIcon, .labelOnly:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasIcon: Bool {
|
||||||
|
switch self {
|
||||||
|
case .labelAndIcon, .iconOnly:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MenuPicker_Previews: PreviewProvider {
|
||||||
|
@State static var value = 0
|
||||||
|
static var previews: some View {
|
||||||
|
MenuPicker(selection: $value, options: [
|
||||||
|
.init(value: 0, title: "Zero"),
|
||||||
|
.init(value: 1, title: "One"),
|
||||||
|
.init(value: 2, title: "Two"),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,7 +18,14 @@ class ProfileFieldsView: UIView {
|
||||||
|
|
||||||
private var isUsingSingleColumn: Bool = false
|
private var isUsingSingleColumn: Bool = false
|
||||||
private var needsSingleColumn: Bool {
|
private var needsSingleColumn: Bool {
|
||||||
traitCollection.preferredContentSizeCategory > .large
|
traitCollection.horizontalSizeClass == .compact && traitCollection.preferredContentSizeCategory > .extraLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
override var accessibilityElements: [Any]? {
|
||||||
|
get {
|
||||||
|
fieldViews.flatMap { [$0.0, $0.1] }
|
||||||
|
}
|
||||||
|
set {}
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
|
|
|
@ -142,14 +142,14 @@ class ProfileHeaderView: UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fieldsView.delegate = delegate
|
||||||
fieldsView.updateUI(account: account)
|
fieldsView.updateUI(account: account)
|
||||||
|
|
||||||
accessibilityElements = [
|
accessibilityElements = [
|
||||||
displayNameLabel!,
|
displayNameLabel!,
|
||||||
usernameLabel!,
|
usernameLabel!,
|
||||||
noteTextView!,
|
noteTextView!,
|
||||||
// TODO: voiceover for fieldsview
|
fieldsView!,
|
||||||
// fieldsView!,
|
|
||||||
moreButton!,
|
moreButton!,
|
||||||
pagesSegmentedControl!,
|
pagesSegmentedControl!,
|
||||||
]
|
]
|
||||||
|
|
|
@ -321,32 +321,67 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
|
|
||||||
// MARK: Accessibility
|
// MARK: Accessibility
|
||||||
|
|
||||||
override var accessibilityLabel: String? {
|
override var isAccessibilityElement: Bool {
|
||||||
|
get { true }
|
||||||
|
set {}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var accessibilityAttributedLabel: NSAttributedString? {
|
||||||
get {
|
get {
|
||||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var str = "\(status.account.displayOrUserName), \(contentTextView.text ?? "")"
|
var str = AttributedString("\(status.account.displayOrUserName), ")
|
||||||
|
if statusState.collapsed ?? false {
|
||||||
|
if !status.spoilerText.isEmpty {
|
||||||
|
str += AttributedString(status.spoilerText)
|
||||||
|
str += ", "
|
||||||
|
}
|
||||||
|
str += "collapsed"
|
||||||
|
} else {
|
||||||
|
str += AttributedString(contentTextView.attributedText)
|
||||||
|
}
|
||||||
|
|
||||||
if status.attachments.count > 0 {
|
if status.attachments.count > 0 {
|
||||||
// TODO: localize me
|
// TODO: localize me
|
||||||
str += ", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")"
|
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")")
|
||||||
}
|
}
|
||||||
if status.poll != nil {
|
if status.poll != nil {
|
||||||
str += ", poll"
|
str += ", poll"
|
||||||
}
|
}
|
||||||
str += ", \(status.createdAt.formatted(.relative(presentation: .numeric)))"
|
str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))")
|
||||||
|
if status.visibility < .unlisted {
|
||||||
|
str += AttributedString(", \(status.visibility.displayName)")
|
||||||
|
}
|
||||||
|
if status.localOnly {
|
||||||
|
str += ", local"
|
||||||
|
}
|
||||||
if let rebloggerID,
|
if let rebloggerID,
|
||||||
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
|
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
|
||||||
str += ", reblogged by \(reblogger.displayOrUserName)"
|
str += AttributedString(", reblogged by \(reblogger.displayOrUserName)")
|
||||||
|
}
|
||||||
|
return NSAttributedString(str)
|
||||||
|
}
|
||||||
|
set {}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var accessibilityHint: String? {
|
||||||
|
get {
|
||||||
|
if statusState.collapsed ?? false {
|
||||||
|
return "Double tap to expand the post."
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return str
|
|
||||||
}
|
}
|
||||||
set {}
|
set {}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func accessibilityActivate() -> Bool {
|
override func accessibilityActivate() -> Bool {
|
||||||
delegate?.selected(status: statusID, state: statusState.copy())
|
if statusState.collapsed ?? false {
|
||||||
|
toggleCollapse()
|
||||||
|
} else {
|
||||||
|
delegate?.selected(status: statusID, state: statusState.copy())
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -249,33 +249,62 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
||||||
|
|
||||||
// MARK: - Accessibility
|
// MARK: - Accessibility
|
||||||
|
|
||||||
override var accessibilityLabel: String? {
|
override var accessibilityAttributedLabel: NSAttributedString? {
|
||||||
get {
|
get {
|
||||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var str = "\(status.account.displayName), \(contentTextView.text ?? "")"
|
var str = AttributedString("\(status.account.displayOrUserName), ")
|
||||||
|
if statusState.collapsed ?? false {
|
||||||
|
if !status.spoilerText.isEmpty {
|
||||||
|
str += AttributedString(status.spoilerText)
|
||||||
|
str += ", "
|
||||||
|
}
|
||||||
|
str += "collapsed"
|
||||||
|
} else {
|
||||||
|
str += AttributedString(contentTextView.attributedText)
|
||||||
|
}
|
||||||
|
|
||||||
if status.attachments.count > 0 {
|
if status.attachments.count > 0 {
|
||||||
// todo: localize me
|
// TODO: localize me
|
||||||
str += ", \(status.attachments.count) attachments"
|
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")")
|
||||||
}
|
}
|
||||||
if status.poll != nil {
|
if status.poll != nil {
|
||||||
str += ", poll"
|
str += ", poll"
|
||||||
}
|
}
|
||||||
str += ", \(TimelineStatusTableViewCell.relativeDateFormatter.localizedString(for: status.createdAt, relativeTo: Date()))"
|
str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))")
|
||||||
if let rebloggerID = rebloggerID,
|
if status.visibility < .unlisted {
|
||||||
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
|
str += AttributedString(", \(status.visibility.displayName)")
|
||||||
str += ", reblogged by \(reblogger.displayName)"
|
|
||||||
}
|
}
|
||||||
|
if status.localOnly {
|
||||||
|
str += ", local"
|
||||||
|
}
|
||||||
|
if let rebloggerID,
|
||||||
|
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
|
||||||
|
str += AttributedString(", reblogged by \(reblogger.displayOrUserName)")
|
||||||
|
}
|
||||||
|
return NSAttributedString(str)
|
||||||
|
}
|
||||||
|
set {}
|
||||||
|
}
|
||||||
|
|
||||||
return str
|
override var accessibilityHint: String? {
|
||||||
|
get {
|
||||||
|
if statusState.collapsed ?? false {
|
||||||
|
return "Double tap to expand the post."
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
set {}
|
set {}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func accessibilityActivate() -> Bool {
|
override func accessibilityActivate() -> Bool {
|
||||||
didSelectCell()
|
if statusState.collapsed ?? false {
|
||||||
|
collapseButtonPressed()
|
||||||
|
} else {
|
||||||
|
didSelectCell()
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue