Compare commits
No commits in common. "dd82283341347db88ed49bb7a1545c8193af0722" and "68c3affacfbc6dbebeae5b65eec28671f1cb6649" have entirely different histories.
dd82283341
...
68c3affacf
39
CHANGELOG.md
39
CHANGELOG.md
|
@ -1,44 +1,5 @@
|
|||
# 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)
|
||||
Features/Improvements:
|
||||
- Dynamic Type support
|
||||
|
|
|
@ -82,14 +82,14 @@ public final class Account: AccountProtocol, Decodable {
|
|||
return Request<Empty>(method: .delete, path: "/api/v1/suggestions/\(account.id)")
|
||||
}
|
||||
|
||||
public static func getFollowers(_ accountID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(accountID)/followers")
|
||||
public static func getFollowers(_ account: Account, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(account.id)/followers")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func getFollowing(_ accountID: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(accountID)/following")
|
||||
public static func getFollowing(_ account: Account, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(account.id)/following")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
@ -112,22 +112,22 @@ public final class Account: AccountProtocol, Decodable {
|
|||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unfollow")
|
||||
}
|
||||
|
||||
public static func block(_ accountID: String) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/block")
|
||||
public static func block(_ account: Account) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/block")
|
||||
}
|
||||
|
||||
public static func unblock(_ accountID: String) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unblock")
|
||||
public static func unblock(_ account: Account) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/unblock")
|
||||
}
|
||||
|
||||
public static func mute(_ accountID: String, notifications: Bool? = nil) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/mute", body: ParametersBody([
|
||||
public static func mute(_ account: Account, notifications: Bool? = nil) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: ParametersBody([
|
||||
"notifications" => notifications
|
||||
]))
|
||||
}
|
||||
|
||||
public static func unmute(_ accountID: String) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unmute")
|
||||
public static func unmute(_ account: Account) -> Request<Relationship> {
|
||||
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/unmute")
|
||||
}
|
||||
|
||||
public static func getLists(_ account: Account) -> Request<[List]> {
|
||||
|
|
|
@ -30,7 +30,11 @@ public class Notification: Decodable {
|
|||
}
|
||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
self.account = try container.decode(Account.self, forKey: .account)
|
||||
self.status = try container.decodeIfPresent(Status.self, forKey: .status)
|
||||
if container.contains(.status) {
|
||||
self.status = try container.decode(Status.self, forKey: .status)
|
||||
} else {
|
||||
self.status = nil
|
||||
}
|
||||
}
|
||||
|
||||
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
|
@ -1,31 +0,0 @@
|
|||
// 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"]),
|
||||
]
|
||||
)
|
|
@ -1,3 +0,0 @@
|
|||
# Duckable
|
||||
|
||||
A package that allows modally-presented view controllers to be 'ducked' to make the content behind them accessible (à la Mail.app).
|
|
@ -1,44 +0,0 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
//
|
||||
// DetentIdentifier.swift
|
||||
// Duckable
|
||||
//
|
||||
// Created by Shadowfacts on 11/7/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UISheetPresentationController.Detent.Identifier {
|
||||
static let bottom = Self("\(Bundle.main.bundleIdentifier!).bottom")
|
||||
}
|
|
@ -1,89 +0,0 @@
|
|||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,217 +0,0 @@
|
|||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
//
|
||||
// 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,6 +76,10 @@
|
|||
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */; };
|
||||
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944E23A9C99800D38C68 /* EditListAccountsViewController.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 */; };
|
||||
D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6289E83217B795D0003D1D7 /* LargeImageViewController.xib */; };
|
||||
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2421217AA7E1005076CC /* UserActivityManager.swift */; };
|
||||
|
@ -148,7 +152,6 @@
|
|||
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 */; };
|
||||
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 */; };
|
||||
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; };
|
||||
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; };
|
||||
|
@ -251,10 +254,6 @@
|
|||
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */; };
|
||||
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.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 */; };
|
||||
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; };
|
||||
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
|
||||
|
@ -271,6 +270,7 @@
|
|||
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.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 */; };
|
||||
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */; };
|
||||
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; };
|
||||
D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; };
|
||||
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */; };
|
||||
|
@ -309,13 +309,6 @@
|
|||
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
|
||||
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; };
|
||||
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 */; };
|
||||
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
@ -436,6 +429,10 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -510,7 +507,6 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -613,10 +609,6 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -633,6 +625,7 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
|
@ -680,13 +673,6 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
@ -701,7 +687,6 @@
|
|||
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
|
||||
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
|
||||
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
|
||||
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -751,6 +736,15 @@
|
|||
path = "Hashtag Cell";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D61959D0241E842400A37B8E /* Draft Cell */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D627FF7C217E958900CC0648 /* DraftTableViewCell.xib */,
|
||||
D627FF7E217E95E000CC0648 /* DraftTableViewCell.swift */,
|
||||
);
|
||||
path = "Draft Cell";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D61959D2241E846D00A37B8E /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -845,12 +839,19 @@
|
|||
children = (
|
||||
D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */,
|
||||
D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */,
|
||||
D6F6A54B291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift */,
|
||||
D6F6A54D291EF7E100F496A8 /* EditListSearchFollowingViewController.swift */,
|
||||
);
|
||||
path = Lists;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D627FF77217E94F200CC0648 /* Drafts */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D627FF78217E950100CC0648 /* DraftsTableViewController.xib */,
|
||||
D627FF7A217E951500CC0648 /* DraftsTableViewController.swift */,
|
||||
);
|
||||
path = Drafts;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D62D241E217AA46B005076CC /* Shortcuts */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -898,12 +899,12 @@
|
|||
D641C787213DD862004B4513 /* Compose */,
|
||||
D641C785213DD83B004B4513 /* Conversation */,
|
||||
D6F2E960249E772F005846BB /* Crash Reporter */,
|
||||
D627FF77217E94F200CC0648 /* Drafts */,
|
||||
D627943C23A5635D00D38C68 /* Explore */,
|
||||
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
|
||||
D641C788213DD86D004B4513 /* Large Image */,
|
||||
D627944B23A9A02400D38C68 /* Lists */,
|
||||
D641C782213DD7F0004B4513 /* Main */,
|
||||
D6F6A555291F4F0C00F496A8 /* Mute */,
|
||||
D641C786213DD852004B4513 /* Notifications */,
|
||||
D641C783213DD7FE004B4513 /* Onboarding */,
|
||||
D641C789213DD87E004B4513 /* Preferences */,
|
||||
|
@ -932,7 +933,6 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */,
|
||||
D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */,
|
||||
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */,
|
||||
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */,
|
||||
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */,
|
||||
|
@ -987,6 +987,7 @@
|
|||
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */,
|
||||
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */,
|
||||
D622759F24F1677200B82A16 /* ComposeHostingController.swift */,
|
||||
D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */,
|
||||
D677284724ECBCB100C732D3 /* ComposeView.swift */,
|
||||
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */,
|
||||
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */,
|
||||
|
@ -1002,8 +1003,6 @@
|
|||
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
|
||||
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */,
|
||||
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */,
|
||||
D6F6A5582920676800F496A8 /* ComposeToolbar.swift */,
|
||||
D6BEA248291C6118002F4D01 /* DraftsView.swift */,
|
||||
);
|
||||
path = Compose;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1277,7 +1276,6 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
|
||||
D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */,
|
||||
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
|
||||
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
|
||||
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
|
||||
|
@ -1291,7 +1289,6 @@
|
|||
D620483323D3801D008A63EF /* LinkTextView.swift */,
|
||||
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */,
|
||||
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */,
|
||||
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */,
|
||||
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
|
||||
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
|
||||
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
|
||||
|
@ -1306,6 +1303,7 @@
|
|||
D626494023C122C800612E6E /* Asset Picker */,
|
||||
D6C7D27B22B6EBE200071952 /* Attachments */,
|
||||
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */,
|
||||
D61959D0241E842400A37B8E /* Draft Cell */,
|
||||
D611C2CC232DC5FC00C86A49 /* Hashtag Cell */,
|
||||
D61AC1DA232EA43100C54D2D /* Instance Cell */,
|
||||
D641C78C213DD937004B4513 /* Notifications */,
|
||||
|
@ -1361,7 +1359,6 @@
|
|||
children = (
|
||||
D63CC703290EC472000E19DE /* Dist.xcconfig */,
|
||||
D674A50727F910F300BA03AC /* Pachyderm */,
|
||||
D6BEA243291A0C83002F4D01 /* Duckable */,
|
||||
D6D4DDCE212518A000E1C4BB /* Tusker */,
|
||||
D6D4DDE3212518A200E1C4BB /* TuskerTests */,
|
||||
D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
|
||||
|
@ -1489,14 +1486,6 @@
|
|||
path = "Crash Reporter";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6F6A555291F4F0C00F496A8 /* Mute */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */,
|
||||
);
|
||||
path = Mute;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6F953F121251A2F00CF0F2B /* API */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1505,9 +1494,6 @@
|
|||
D6E9CDA7281A427800BBC98E /* PostService.swift */,
|
||||
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */,
|
||||
D621733228F1D5ED004C7DB1 /* ReblogService.swift */,
|
||||
D6F6A54F291F058600F496A8 /* CreateListService.swift */,
|
||||
D6F6A551291F098700F496A8 /* RenameListService.swift */,
|
||||
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */,
|
||||
);
|
||||
path = API;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1539,7 +1525,6 @@
|
|||
D674A50827F9128D00BA03AC /* Pachyderm */,
|
||||
D6552366289870790048A653 /* ScreenCorners */,
|
||||
D63CC701290EC0B8000E19DE /* Sentry */,
|
||||
D6BEA244291A0EDE002F4D01 /* Duckable */,
|
||||
);
|
||||
productName = Tusker;
|
||||
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
|
||||
|
@ -1674,6 +1659,7 @@
|
|||
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */,
|
||||
D626493923C0FD0000612E6E /* AllPhotosTableViewCell.xib in Resources */,
|
||||
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */,
|
||||
D627FF7D217E958900CC0648 /* DraftTableViewCell.xib in Resources */,
|
||||
D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */,
|
||||
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */,
|
||||
D6C82B5725C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib in Resources */,
|
||||
|
@ -1685,6 +1671,7 @@
|
|||
D663625D2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib in Resources */,
|
||||
D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */,
|
||||
D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */,
|
||||
D627FF79217E950100CC0648 /* DraftsTableViewController.xib in Resources */,
|
||||
D626493D23C1000300612E6E /* AlbumTableViewCell.xib in Resources */,
|
||||
D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */,
|
||||
D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */,
|
||||
|
@ -1777,7 +1764,6 @@
|
|||
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */,
|
||||
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
|
||||
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
|
||||
D6F6A54C291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift in Sources */,
|
||||
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */,
|
||||
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
|
||||
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
|
||||
|
@ -1818,7 +1804,6 @@
|
|||
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
|
||||
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */,
|
||||
D627943723A552C200D38C68 /* BookmarkStatusActivity.swift in Sources */,
|
||||
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
|
||||
D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */,
|
||||
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
|
||||
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
|
||||
|
@ -1865,10 +1850,9 @@
|
|||
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
||||
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
|
||||
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
|
||||
D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */,
|
||||
D627FF7B217E951500CC0648 /* DraftsTableViewController.swift in Sources */,
|
||||
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */,
|
||||
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
|
||||
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
|
||||
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
|
||||
D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */,
|
||||
D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */,
|
||||
|
@ -1879,6 +1863,7 @@
|
|||
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
|
||||
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */,
|
||||
D61DC84628F498F200B82C6E /* Logging.swift in Sources */,
|
||||
D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */,
|
||||
D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */,
|
||||
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */,
|
||||
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */,
|
||||
|
@ -1888,6 +1873,7 @@
|
|||
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
|
||||
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
|
||||
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
|
||||
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */,
|
||||
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */,
|
||||
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
|
||||
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
|
||||
|
@ -1902,7 +1888,6 @@
|
|||
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
|
||||
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
|
||||
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
|
||||
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */,
|
||||
D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */,
|
||||
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
|
||||
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */,
|
||||
|
@ -1936,8 +1921,6 @@
|
|||
D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */,
|
||||
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
|
||||
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
|
||||
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */,
|
||||
D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */,
|
||||
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
|
||||
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */,
|
||||
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
|
||||
|
@ -1995,7 +1978,6 @@
|
|||
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
|
||||
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
|
||||
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
||||
D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */,
|
||||
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
|
||||
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */,
|
||||
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */,
|
||||
|
@ -2004,16 +1986,13 @@
|
|||
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */,
|
||||
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
|
||||
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
|
||||
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */,
|
||||
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
|
||||
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
|
||||
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
|
||||
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
|
||||
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */,
|
||||
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
|
||||
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
|
||||
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
|
||||
D6F6A54E291EF7E100F496A8 /* EditListSearchFollowingViewController.swift in Sources */,
|
||||
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
|
||||
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
|
||||
D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */,
|
||||
|
@ -2187,7 +2166,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2255,7 +2234,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
|
@ -2405,7 +2384,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2434,7 +2413,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2544,7 +2523,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
|
@ -2571,7 +2550,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
|
@ -2710,10 +2689,6 @@
|
|||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Pachyderm;
|
||||
};
|
||||
D6BEA244291A0EDE002F4D01 /* Duckable */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = Duckable;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
|
||||
/* Begin XCVersionGroup section */
|
||||
|
|
|
@ -1,70 +0,0 @@
|
|||
//
|
||||
// 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")
|
||||
}
|
|
@ -1,65 +0,0 @@
|
|||
//
|
||||
// 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,12 +10,11 @@ import Foundation
|
|||
import Pachyderm
|
||||
|
||||
struct InstanceFeatures {
|
||||
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive)
|
||||
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; Pleroma (.*)\\)")
|
||||
|
||||
private(set) var instanceType = InstanceType.mastodon
|
||||
private(set) var version: Version?
|
||||
private(set) var pleromaVersion: Version?
|
||||
private(set) var hometownVersion: Version?
|
||||
private(set) var maxStatusChars = 500
|
||||
|
||||
var localOnlyPosts: Bool {
|
||||
|
@ -31,7 +30,7 @@ struct InstanceFeatures {
|
|||
}
|
||||
|
||||
var boostToOriginalAudience: Bool {
|
||||
instanceType == .pleroma || instanceType.isMastodon
|
||||
instanceType == .pleroma || instanceType == .mastodon
|
||||
}
|
||||
|
||||
var profilePinnedStatuses: Bool {
|
||||
|
@ -39,16 +38,16 @@ struct InstanceFeatures {
|
|||
}
|
||||
|
||||
var trends: Bool {
|
||||
instanceType.isMastodon
|
||||
instanceType == .mastodon
|
||||
}
|
||||
|
||||
var trendingStatusesAndLinks: Bool {
|
||||
instanceType.isMastodon && hasVersion(3, 5, 0)
|
||||
instanceType == .mastodon && hasVersion(3, 5, 0)
|
||||
}
|
||||
|
||||
var reblogVisibility: Bool {
|
||||
(instanceType.isMastodon && hasVersion(2, 8, 0))
|
||||
|| (instanceType == .pleroma && hasPleromaVersion(2, 0, 0))
|
||||
(instanceType == .mastodon && hasVersion(2, 8, 0))
|
||||
|| (instanceType == .pleroma && hasVersion(2, 0, 0))
|
||||
}
|
||||
|
||||
var probablySupportsMarkdown: Bool {
|
||||
|
@ -56,31 +55,24 @@ struct InstanceFeatures {
|
|||
}
|
||||
|
||||
mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
|
||||
var version: Version?
|
||||
|
||||
let ver = instance.version.lowercased()
|
||||
if ver.contains("glitch") {
|
||||
instanceType = .glitch
|
||||
} else if nodeInfo?.software.name == "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") {
|
||||
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") {
|
||||
instanceType = .pixelfed
|
||||
} else {
|
||||
instanceType = .mastodon
|
||||
}
|
||||
|
||||
self.version = version ?? Version(string: ver)
|
||||
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
|
||||
}
|
||||
|
@ -92,14 +84,6 @@ struct InstanceFeatures {
|
|||
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 {
|
||||
|
|
|
@ -50,7 +50,7 @@ class ReblogService {
|
|||
}
|
||||
} else {
|
||||
image = nil
|
||||
reblogVisibilityActions = nil
|
||||
reblogVisibilityActions = []
|
||||
}
|
||||
|
||||
let preview = ConfirmReblogStatusPreviewView(status: status)
|
||||
|
|
|
@ -1,69 +0,0 @@
|
|||
//
|
||||
// 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,7 +81,10 @@ class ImageCache {
|
|||
guard !ImageCache.disableCaching else { return }
|
||||
|
||||
if !((try? cache.has(url.absoluteString)) ?? false) {
|
||||
let task = dataTask(url: url, completion: nil)
|
||||
let task = dataTask(url: url) { data, image in
|
||||
guard let data else { return }
|
||||
try? self.cache.set(url.absoluteString, data: data, image: image)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
@ -92,9 +95,7 @@ class ImageCache {
|
|||
let data else {
|
||||
return
|
||||
}
|
||||
let image = UIImage(data: data)
|
||||
try? self.cache.set(url.absoluteString, data: data, image: image)
|
||||
completion?(data, image)
|
||||
completion?(data, UIImage(data: data))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -221,11 +221,10 @@ class MastodonCachePersistentStore: NSPersistentContainer {
|
|||
}
|
||||
}
|
||||
|
||||
func addAll(accounts: [Account], in context: NSManagedObjectContext? = nil, completion: (() -> Void)? = nil) {
|
||||
let context = context ?? backgroundContext
|
||||
context.perform {
|
||||
accounts.forEach { self.upsert(account: $0, in: context) }
|
||||
self.save(context: context)
|
||||
func addAll(accounts: [Account], completion: (() -> Void)? = nil) {
|
||||
backgroundContext.perform {
|
||||
accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
|
||||
self.save(context: self.backgroundContext)
|
||||
completion?()
|
||||
accounts.forEach { self.accountSubject.send($0.id) }
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21512" systemVersion="22A380" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="20086" systemVersion="21E230" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Account" representedClassName="AccountMO" syncable="YES">
|
||||
<attribute name="acct" attributeType="String"/>
|
||||
<attribute name="avatar" optional="YES" attributeType="URI"/>
|
||||
|
@ -21,7 +21,6 @@
|
|||
<attribute name="username" attributeType="String"/>
|
||||
<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="statuses" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Status" inverseName="account" inverseEntity="Status"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
|
@ -85,7 +84,7 @@
|
|||
<attribute name="uri" attributeType="String"/>
|
||||
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||
<attribute name="visibilityString" attributeType="String"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/>
|
||||
<relationship name="account" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
|
||||
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
|
@ -93,4 +92,11 @@
|
|||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</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>
|
|
@ -56,7 +56,6 @@ private let imageType = UTType.image.identifier
|
|||
private let mp4Type = UTType.mpeg4Movie.identifier
|
||||
private let quickTimeType = UTType.quickTimeMovie.identifier
|
||||
private let dataType = UTType.data.identifier
|
||||
private let gifType = UTType.gif.identifier
|
||||
|
||||
extension CompositionAttachment: NSItemProviderWriting {
|
||||
static var writableTypeIdentifiersForItemProvider: [String] {
|
||||
|
@ -96,22 +95,20 @@ extension CompositionAttachment: NSItemProviderReading {
|
|||
[typeIdentifier] + UIImage.readableTypeIdentifiersForItemProvider + [mp4Type, quickTimeType] + NSURL.readableTypeIdentifiersForItemProvider
|
||||
}
|
||||
|
||||
static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> CompositionAttachment {
|
||||
static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> Self {
|
||||
if typeIdentifier == CompositionAttachment.typeIdentifier {
|
||||
return try PropertyListDecoder().decode(CompositionAttachment.self, from: data)
|
||||
} else if typeIdentifier == gifType {
|
||||
return CompositionAttachment(data: .gif(data))
|
||||
return try PropertyListDecoder().decode(Self.self, from: data)
|
||||
} else if UIImage.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let image = try? UIImage.object(withItemProviderData: data, typeIdentifier: typeIdentifier) {
|
||||
return CompositionAttachment(data: .image(image))
|
||||
return CompositionAttachment(data: .image(image)) as! Self
|
||||
} else if let type = UTType(typeIdentifier), type == .mpeg4Movie || type == .quickTimeMovie {
|
||||
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let temporaryFileName = ProcessInfo().globallyUniqueString
|
||||
let fileExt = type.preferredFilenameExtension!
|
||||
let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName).appendingPathExtension(fileExt)
|
||||
try data.write(to: temporaryFileURL)
|
||||
return CompositionAttachment(data: .video(temporaryFileURL))
|
||||
return CompositionAttachment(data: .video(temporaryFileURL)) as! Self
|
||||
} else if NSURL.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let url = try? NSURL.object(withItemProviderData: data, typeIdentifier: typeIdentifier) as URL {
|
||||
return CompositionAttachment(data: .video(url))
|
||||
return CompositionAttachment(data: .video(url)) as! Self
|
||||
} else {
|
||||
throw ItemProviderError.incompatibleTypeIdentifier
|
||||
}
|
||||
|
|
|
@ -16,7 +16,6 @@ enum CompositionAttachmentData {
|
|||
case image(UIImage)
|
||||
case video(URL)
|
||||
case drawing(PKDrawing)
|
||||
case gif(Data)
|
||||
|
||||
var type: AttachmentType {
|
||||
switch self {
|
||||
|
@ -28,8 +27,6 @@ enum CompositionAttachmentData {
|
|||
return .video
|
||||
case .drawing(_):
|
||||
return .image
|
||||
case .gif(_):
|
||||
return .image
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -70,24 +67,15 @@ enum CompositionAttachmentData {
|
|||
completion(.failure(.missingData))
|
||||
return
|
||||
}
|
||||
|
||||
let utType: UTType
|
||||
let image = CIImage(data: data)!
|
||||
let needsColorSpaceConversion = image.colorSpace?.name != CGColorSpace.sRGB
|
||||
|
||||
// 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 utType: UTType
|
||||
if dataUTI == "public.heic" {
|
||||
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
|
||||
let image = CIImage(data: data)!
|
||||
let context = CIContext()
|
||||
let sRGB = CGColorSpace(name: CGColorSpace.sRGB)!
|
||||
if dataUTI == "public.png" {
|
||||
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: sRGB)!
|
||||
utType = .png
|
||||
} else {
|
||||
data = context.jpegRepresentation(of: image, colorSpace: sRGB)!
|
||||
utType = .jpeg
|
||||
}
|
||||
let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!
|
||||
data = context.jpegRepresentation(of: image, colorSpace: colorSpace, options: [:])!
|
||||
utType = .jpeg
|
||||
} else {
|
||||
utType = UTType(dataUTI)!
|
||||
}
|
||||
|
@ -122,8 +110,6 @@ enum CompositionAttachmentData {
|
|||
case let .drawing(drawing):
|
||||
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
|
||||
completion(.success((image.pngData()!, .png)))
|
||||
case let .gif(data):
|
||||
completion(.success((data, .gif)))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -196,8 +182,6 @@ extension CompositionAttachmentData: Codable {
|
|||
try container.encode("drawing", forKey: .type)
|
||||
let drawingData = drawing.dataRepresentation()
|
||||
try container.encode(drawingData, forKey: .drawing)
|
||||
case .gif(_):
|
||||
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: [], debugDescription: "gif CompositionAttachments cannot be encoded"))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -221,7 +205,7 @@ extension CompositionAttachmentData: Codable {
|
|||
let drawing = try PKDrawing(data: drawingData)
|
||||
self = .drawing(drawing)
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of image, asset, or drawing")
|
||||
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of 'image' or 'asset'")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -107,8 +107,6 @@ extension Draft: Equatable {
|
|||
}
|
||||
}
|
||||
|
||||
extension Draft: Identifiable {}
|
||||
|
||||
extension Draft {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
class DraftsManager: Codable, ObservableObject {
|
||||
class DraftsManager: Codable {
|
||||
|
||||
private(set) static var shared: DraftsManager = load()
|
||||
|
||||
|
@ -48,12 +48,7 @@ class DraftsManager: Codable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
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] = [:]
|
||||
private var drafts: [UUID: Draft] = [:]
|
||||
var sorted: [Draft] {
|
||||
return drafts.values.sorted(by: { $0.lastModified > $1.lastModified })
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
enum StatusFormat: Int, CaseIterable {
|
||||
enum StatusFormat: CaseIterable {
|
||||
case bold, italics, strikethrough, code
|
||||
|
||||
var insertionResult: FormatInsertionResult? {
|
||||
|
@ -23,17 +23,19 @@ enum StatusFormat: Int, CaseIterable {
|
|||
}
|
||||
}
|
||||
|
||||
var imageName: String? {
|
||||
var image: UIImage? {
|
||||
let name: String
|
||||
switch self {
|
||||
case .italics:
|
||||
return "italic"
|
||||
name = "italic"
|
||||
case .bold:
|
||||
return "bold"
|
||||
name = "bold"
|
||||
case .strikethrough:
|
||||
return "strikethrough"
|
||||
name = "strikethrough"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return UIImage(systemName: name)
|
||||
}
|
||||
|
||||
var title: (String, [NSAttributedString.Key: Any])? {
|
||||
|
|
|
@ -10,7 +10,6 @@ import UIKit
|
|||
import Pachyderm
|
||||
import MessageUI
|
||||
import CoreData
|
||||
import Duckable
|
||||
|
||||
class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
|
||||
|
||||
|
@ -32,10 +31,10 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
|
||||
window = UIWindow(windowScene: windowScene)
|
||||
|
||||
showAppOrOnboardingUI(session: session)
|
||||
if connectionOptions.urlContexts.count > 0 {
|
||||
self.scene(scene, openURLContexts: connectionOptions.urlContexts)
|
||||
}
|
||||
showAppOrOnboardingUI(session: session)
|
||||
if connectionOptions.urlContexts.count > 0 {
|
||||
self.scene(scene, openURLContexts: connectionOptions.urlContexts)
|
||||
}
|
||||
|
||||
window!.makeKeyAndVisible()
|
||||
|
||||
|
@ -126,19 +125,12 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest()
|
||||
statusReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate)
|
||||
let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq)
|
||||
deleteStatusReq.resultType = .resultTypeCount
|
||||
if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult {
|
||||
Logging.general.info("Pruned \(res.result as! Int) statuses")
|
||||
}
|
||||
|
||||
_ = try? context.execute(deleteStatusReq)
|
||||
|
||||
let accountReq: NSFetchRequest<NSFetchRequestResult> = AccountMO.fetchRequest()
|
||||
accountReq.predicate = NSPredicate(format: "((lastFetchedAt = nil) OR (lastFetchedAt < %@)) AND (statuses.@count = 0)", minDate as NSDate)
|
||||
accountReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate)
|
||||
let deleteAccountReq = NSBatchDeleteRequest(fetchRequest: accountReq)
|
||||
deleteAccountReq.resultType = .resultTypeCount
|
||||
if let res = try? context.execute(deleteAccountReq) as? NSBatchDeleteResult {
|
||||
Logging.general.info("Pruned \(res.result as! Int) accounts")
|
||||
}
|
||||
_ = try? context.execute(deleteAccountReq)
|
||||
|
||||
try? context.save()
|
||||
}
|
||||
|
@ -206,14 +198,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
mastodonController.getOwnAccount()
|
||||
mastodonController.getOwnInstance()
|
||||
|
||||
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
|
||||
}
|
||||
return MainSplitViewController(mastodonController: mastodonController)
|
||||
}
|
||||
|
||||
func createOnboardingUI() -> UIViewController {
|
||||
|
|
|
@ -13,14 +13,18 @@ import AVKit
|
|||
|
||||
class AssetPreviewViewController: UIViewController {
|
||||
|
||||
let asset: PHAsset
|
||||
let attachment: CompositionAttachmentData
|
||||
|
||||
init(asset: PHAsset) {
|
||||
self.asset = asset
|
||||
|
||||
init(attachment: CompositionAttachmentData) {
|
||||
self.attachment = attachment
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
convenience init(asset: PHAsset) {
|
||||
self.init(attachment: .asset(asset))
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
@ -30,17 +34,27 @@ class AssetPreviewViewController: UIViewController {
|
|||
|
||||
view.backgroundColor = .black
|
||||
|
||||
switch asset.mediaType {
|
||||
case .image:
|
||||
if asset.mediaSubtypes.contains(.photoLive) {
|
||||
showLivePhoto(asset)
|
||||
} else {
|
||||
showAssetImage(asset)
|
||||
switch attachment {
|
||||
case let .image(image):
|
||||
showImage(image)
|
||||
case let .video(url):
|
||||
showVideo(asset: AVURLAsset(url: url))
|
||||
case let .asset(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 .video:
|
||||
showAssetVideo(asset)
|
||||
default:
|
||||
fatalError("asset mediaType must be image or video")
|
||||
case let .drawing(drawing):
|
||||
let image = drawing.imageInLightMode(from: drawing.bounds)
|
||||
showImage(image)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,30 +10,14 @@ import UIKit
|
|||
import AVKit
|
||||
import Pachyderm
|
||||
|
||||
class GalleryPlayerViewController: UIViewController {
|
||||
class GalleryPlayerViewController: AVPlayerViewController {
|
||||
|
||||
let playerVC = AVPlayerViewController()
|
||||
|
||||
var attachment: Attachment!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
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),
|
||||
])
|
||||
allowsPictureInPicturePlayback = true
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
|
|
|
@ -93,8 +93,8 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
|
|||
return vc
|
||||
case .video, .audio:
|
||||
let vc = GalleryPlayerViewController()
|
||||
vc.playerVC.player = AVPlayer(url: attachment.url)
|
||||
vc.playerVC.delegate = avPlayerViewControllerDelegate
|
||||
vc.player = AVPlayer(url: attachment.url)
|
||||
vc.delegate = avPlayerViewControllerDelegate
|
||||
vc.attachment = attachment
|
||||
return vc
|
||||
case .gifv:
|
||||
|
|
|
@ -13,7 +13,6 @@ struct ComposeAttachmentImage: View {
|
|||
let attachment: CompositionAttachment
|
||||
let fullSize: Bool
|
||||
|
||||
@State private var gifData: Data? = nil
|
||||
@State private var image: UIImage? = nil
|
||||
@State private var imageContentMode: ContentMode = .fill
|
||||
@State private var imageBackgroundColor: Color = .black
|
||||
|
@ -21,9 +20,7 @@ struct ComposeAttachmentImage: View {
|
|||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
if let gifData {
|
||||
GIFViewWrapper(gifData: gifData)
|
||||
} else if let image {
|
||||
if let image = image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: imageContentMode)
|
||||
|
@ -57,23 +54,9 @@ struct ComposeAttachmentImage: View {
|
|||
// currently only used as thumbnail in ComposeAttachmentRow
|
||||
size = CGSize(width: 80, height: 80)
|
||||
}
|
||||
let isGIF = PHAssetResource.assetResources(for: asset).contains(where: { $0.uniformTypeIdentifier == UTType.gif.identifier })
|
||||
if isGIF {
|
||||
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
|
||||
}
|
||||
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
|
||||
DispatchQueue.main.async {
|
||||
self.image = image
|
||||
}
|
||||
}
|
||||
case let .video(url):
|
||||
|
@ -86,35 +69,10 @@ struct ComposeAttachmentImage: View {
|
|||
image = drawing.imageInLightMode(from: drawing.bounds)
|
||||
imageContentMode = .fit
|
||||
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 {
|
||||
static var previews: some View {
|
||||
ComposeAttachmentImage(attachment: CompositionAttachment(data: .image(UIImage())), fullSize: false)
|
||||
|
|
|
@ -14,6 +14,7 @@ import Vision
|
|||
struct ComposeAttachmentRow: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@ObservedObject var attachment: CompositionAttachment
|
||||
let heightChanged: (CGFloat) -> Void
|
||||
|
||||
@EnvironmentObject var uiState: ComposeUIState
|
||||
@State private var mode: Mode = .allowEntry
|
||||
|
@ -46,6 +47,7 @@ struct ComposeAttachmentRow: View {
|
|||
switch mode {
|
||||
case .allowEntry:
|
||||
ComposeTextView(text: $attachment.attachmentDescription, placeholder: Text("Describe for the visually impaired…"), minHeight: 80)
|
||||
.heightDidChange(self.heightChanged)
|
||||
.backgroundColor(.clear)
|
||||
|
||||
case .recognizingText:
|
||||
|
|
|
@ -18,17 +18,23 @@ struct ComposeAttachmentsList: View {
|
|||
@EnvironmentObject var uiState: ComposeUIState
|
||||
@State var isShowingAssetPickerPopover = false
|
||||
@State var isShowingCreateDrawing = false
|
||||
@State var rowHeights = [UUID: CGFloat]()
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
List {
|
||||
ForEach(draft.attachments) { (attachment) in
|
||||
ComposeAttachmentRow(
|
||||
draft: draft,
|
||||
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))
|
||||
.onDrag { NSItemProvider(object: attachment) }
|
||||
}
|
||||
|
@ -63,7 +69,12 @@ struct ComposeAttachmentsList: View {
|
|||
.frame(height: cellHeight / 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)
|
||||
.onReceive(draft.$attachments, perform: self.attachmentsChanged)
|
||||
}
|
||||
|
||||
private var addButtonImageName: String {
|
||||
|
@ -93,6 +104,13 @@ 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() {
|
||||
if #available(iOS 16.0, *) {
|
||||
// these appearance proxy hacks are no longer necessary
|
||||
|
@ -104,6 +122,17 @@ 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 {
|
||||
ComposeAssetPicker(draft: draft, delegate: uiState.delegate?.assetPickerDelegate)
|
||||
.onDisappear {
|
||||
|
@ -185,6 +214,16 @@ fileprivate extension View {
|
|||
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, *)
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// 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,19 +13,15 @@ struct ComposeEmojiTextField: UIViewRepresentable {
|
|||
|
||||
@EnvironmentObject private var uiState: ComposeUIState
|
||||
|
||||
@Binding var text: String
|
||||
let placeholder: String
|
||||
let becomeFirstResponder: Binding<Bool>?
|
||||
let focusNextView: Binding<Bool>?
|
||||
private var didChange: ((String) -> Void)? = nil
|
||||
private var didEndEditing: (() -> Void)? = nil
|
||||
@Binding private var text: String
|
||||
private let placeholder: String
|
||||
private var didChange: ((String) -> Void)?
|
||||
private var didEndEditing: (() -> Void)?
|
||||
private var backgroundColor: UIColor? = nil
|
||||
|
||||
init(text: Binding<String>, placeholder: String, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) {
|
||||
init(text: Binding<String>, placeholder: String) {
|
||||
self._text = text
|
||||
self.placeholder = placeholder
|
||||
self.becomeFirstResponder = becomeFirstResponder
|
||||
self.focusNextView = focusNextView
|
||||
self.didChange = nil
|
||||
self.didEndEditing = nil
|
||||
}
|
||||
|
@ -56,7 +52,6 @@ struct ComposeEmojiTextField: UIViewRepresentable {
|
|||
|
||||
view.delegate = context.coordinator
|
||||
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
|
||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
@ -76,14 +71,6 @@ struct ComposeEmojiTextField: UIViewRepresentable {
|
|||
}
|
||||
context.coordinator.didChange = didChange
|
||||
context.coordinator.didEndEditing = didEndEditing
|
||||
context.coordinator.focusNextView = focusNextView
|
||||
|
||||
if becomeFirstResponder?.wrappedValue == true {
|
||||
DispatchQueue.main.async {
|
||||
uiView.becomeFirstResponder()
|
||||
becomeFirstResponder?.wrappedValue = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
|
@ -97,7 +84,6 @@ struct ComposeEmojiTextField: UIViewRepresentable {
|
|||
unowned var uiState: ComposeUIState!
|
||||
var didChange: ((String) -> Void)?
|
||||
var didEndEditing: (() -> Void)?
|
||||
var focusNextView: Binding<Bool>?
|
||||
|
||||
var skipSettingTextOnNextUpdate = false
|
||||
|
||||
|
@ -110,17 +96,12 @@ struct ComposeEmojiTextField: UIViewRepresentable {
|
|||
didChange?(text.wrappedValue)
|
||||
}
|
||||
|
||||
@objc func returnKeyPressed() {
|
||||
focusNextView?.wrappedValue = true
|
||||
}
|
||||
|
||||
func textFieldDidBeginEditing(_ textField: UITextField) {
|
||||
uiState.currentInput = self
|
||||
updateAutocompleteState(textField: textField)
|
||||
}
|
||||
|
||||
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||
uiState.currentInput = nil
|
||||
updateAutocompleteState(textField: textField)
|
||||
didEndEditing?()
|
||||
}
|
||||
|
|
|
@ -10,16 +10,14 @@ import SwiftUI
|
|||
import Combine
|
||||
import Pachyderm
|
||||
import PencilKit
|
||||
import Duckable
|
||||
|
||||
protocol ComposeHostingControllerDelegate: AnyObject {
|
||||
func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool
|
||||
}
|
||||
|
||||
class ComposeHostingController: UIHostingController<ComposeView>, DuckableViewController {
|
||||
class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
||||
|
||||
weak var delegate: ComposeHostingControllerDelegate?
|
||||
weak var duckableDelegate: DuckableViewControllerDelegate?
|
||||
|
||||
let mastodonController: MastodonController
|
||||
|
||||
|
@ -29,6 +27,13 @@ class ComposeHostingController: UIHostingController<ComposeView>, DuckableViewCo
|
|||
|
||||
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) {
|
||||
self.mastodonController = mastodonController
|
||||
let realDraft = draft ?? Draft(accountID: mastodonController.accountInfo!.id)
|
||||
|
@ -36,18 +41,49 @@ class ComposeHostingController: UIHostingController<ComposeView>, DuckableViewCo
|
|||
|
||||
self.uiState = ComposeUIState(draft: realDraft)
|
||||
|
||||
let compose = ComposeView(
|
||||
// we need our own environment object wrapper so that we can set the mastodon controller as an
|
||||
// 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,
|
||||
uiState: uiState
|
||||
)
|
||||
super.init(rootView: compose)
|
||||
super.init(rootView: container)
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
.flatMap(\.objectWillChange)
|
||||
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility))
|
||||
|
@ -55,12 +91,32 @@ class ComposeHostingController: UIHostingController<ComposeView>, DuckableViewCo
|
|||
DraftsManager.save()
|
||||
}
|
||||
.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) {
|
||||
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) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
|
@ -70,6 +126,159 @@ class ComposeHostingController: UIHostingController<ComposeView>, DuckableViewCo
|
|||
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 {
|
||||
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false }
|
||||
if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||
|
@ -92,18 +301,6 @@ class ComposeHostingController: UIHostingController<ComposeView>, DuckableViewCo
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@objc func cwButtonPressed() {
|
||||
|
@ -124,7 +321,9 @@ class ComposeHostingController: UIHostingController<ComposeView>, DuckableViewCo
|
|||
}
|
||||
|
||||
@objc func draftsButtonPresed() {
|
||||
uiState.isShowingDraftsList = true
|
||||
let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!, exclude: draft)
|
||||
draftsVC.delegate = self
|
||||
present(UINavigationController(rootViewController: draftsVC), animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -136,7 +335,6 @@ extension ComposeHostingController: ComposeUIStateDelegate {
|
|||
let dismissed = delegate?.dismissCompose(mode: mode) ?? false
|
||||
if !dismissed {
|
||||
self.dismiss(animated: true)
|
||||
self.duckableDelegate?.duckableViewControllerWillDismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -164,16 +362,6 @@ extension ComposeHostingController: ComposeUIStateDelegate {
|
|||
|
||||
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 {
|
||||
|
@ -200,16 +388,46 @@ extension ComposeHostingController: AssetPickerViewControllerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// superseded by duckable stuff
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
extension ComposeHostingController: DraftsTableViewControllerDelegate {
|
||||
func draftSelectionCanceled() {
|
||||
}
|
||||
|
||||
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 {
|
||||
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
|
||||
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) {
|
||||
uiState.isShowingSaveDraftSheet = true
|
||||
|
|
|
@ -44,8 +44,6 @@ struct ComposePollView: View {
|
|||
.imageScale(.small)
|
||||
.padding(4)
|
||||
}
|
||||
.accessibilityLabel("Remove poll")
|
||||
.buttonStyle(.plain)
|
||||
.accentColor(buttonForegroundColor)
|
||||
.background(Circle().foregroundColor(buttonBackgroundColor))
|
||||
.hoverEffect()
|
||||
|
@ -54,22 +52,31 @@ struct ComposePollView: View {
|
|||
ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in
|
||||
ComposePollOption(poll: poll, option: e.element, optionIndex: e.offset)
|
||||
}
|
||||
.transition(.slide)
|
||||
|
||||
Button(action: self.addOption) {
|
||||
Label("Add Option", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
|
||||
|
||||
HStack {
|
||||
MenuPicker(selection: $poll.multiple, options: [
|
||||
.init(value: true, title: "Allow multiple"),
|
||||
.init(value: false, title: "Single choice"),
|
||||
])
|
||||
// use .animation(nil) on pickers so frame doesn't have a size change animation when the text changes
|
||||
// this is deprecated in iOS 15, but using .animation(nil, value: poll.multiple) does not work (it still animates)
|
||||
// nor does setting that on the Text rather than the Picker
|
||||
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)
|
||||
|
||||
MenuPicker(selection: $duration, options: Duration.allCases.map {
|
||||
.init(value: $0, title: ComposePollView.formatter.string(from: $0.timeInterval)!)
|
||||
})
|
||||
Picker(selection: $duration, label: Text(verbatim: ComposePollView.formatter.string(from: duration.timeInterval)!)) {
|
||||
ForEach(Duration.allCases, id: \.self) { (duration) in
|
||||
Text(ComposePollView.formatter.string(from: duration.timeInterval)!).tag(duration)
|
||||
}
|
||||
}
|
||||
.animation(nil)
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
@ -103,7 +110,9 @@ struct ComposePollView: View {
|
|||
}
|
||||
|
||||
private func addOption() {
|
||||
poll.options.append(Draft.Poll.Option(""))
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
poll.options.append(Draft.Poll.Option(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -154,7 +163,6 @@ struct ComposePollOption: View {
|
|||
Button(action: self.removeOption) {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(poll.options.count == 1 ? .gray : .red)
|
||||
.disabled(poll.options.count == 1)
|
||||
.hoverEffect()
|
||||
|
@ -167,7 +175,9 @@ struct ComposePollOption: View {
|
|||
}
|
||||
|
||||
private func removeOption() {
|
||||
poll.options.remove(at: optionIndex)
|
||||
_ = withAnimation {
|
||||
poll.options.remove(at: optionIndex)
|
||||
}
|
||||
}
|
||||
|
||||
struct Checkbox: View {
|
||||
|
|
|
@ -17,20 +17,18 @@ struct ComposeReplyContentView: UIViewRepresentable {
|
|||
|
||||
let heightChanged: (CGFloat) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> UIViewType {
|
||||
func makeUIView(context: Context) -> ComposeReplyContentTextView {
|
||||
let view = ComposeReplyContentTextView()
|
||||
view.overrideMastodonController = mastodonController
|
||||
view.setTextFrom(status: status)
|
||||
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.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIViewType, context: Context) {
|
||||
func updateUIView(_ uiView: ComposeReplyContentTextView, context: Context) {
|
||||
uiView.heightChanged = heightChanged
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,8 +10,7 @@ import SwiftUI
|
|||
|
||||
struct ComposeReplyView: View {
|
||||
let status: StatusMO
|
||||
let rowTopInset: CGFloat
|
||||
let globalFrameOutsideList: CGRect
|
||||
let stackPadding: CGFloat
|
||||
|
||||
@State private var displayNameHeight: CGFloat?
|
||||
@State private var contentHeight: CGFloat?
|
||||
|
@ -45,13 +44,9 @@ struct ComposeReplyView: View {
|
|||
displayNameHeight = newValue
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
ComposeReplyContentView(status: status) { newHeight in
|
||||
// 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
|
||||
}
|
||||
contentHeight = newHeight
|
||||
}
|
||||
.frame(height: contentHeight ?? 0)
|
||||
}
|
||||
|
@ -60,22 +55,20 @@ struct ComposeReplyView: View {
|
|||
}
|
||||
|
||||
private func replyAvatarImage(geometry: GeometryProxy) -> some View {
|
||||
// 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 rowTopInset so that the image is always at least rowTopInset away from the top
|
||||
var offset = scrollOffset + rowTopInset
|
||||
let scrollOffset = -geometry.frame(in: .named(ComposeView.coordinateSpaceOutsideOfScrollView)).minY
|
||||
|
||||
// add stackPadding so that the image is always at least stackPadding away from the top
|
||||
var offset = scrollOffset + stackPadding
|
||||
|
||||
// offset can never be less than 0 (i.e., above the top of the in-reply-to content)
|
||||
offset = max(offset, 0)
|
||||
|
||||
// subtract 50, because we care about where the bottom of the view is but the offset is relative to the top of the view
|
||||
let maxOffset = max((contentHeight ?? 0) + (displayNameHeight ?? 0) - 50, 0)
|
||||
|
||||
|
||||
// once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
|
||||
offset = min(offset, maxOffset)
|
||||
|
||||
|
||||
return ComposeAvatarImageView(url: status.account.avatar)
|
||||
.frame(width: 50, height: 50)
|
||||
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
|
||||
|
|
|
@ -69,8 +69,6 @@ struct WrappedTextView: UIViewRepresentable {
|
|||
var textDidChange: ((UITextView) -> Void)?
|
||||
var font = UIFont.systemFont(ofSize: 20)
|
||||
|
||||
@Environment(\.isEnabled) private var isEnabled: Bool
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let textView = UITextView()
|
||||
textView.delegate = context.coordinator
|
||||
|
@ -84,8 +82,6 @@ struct WrappedTextView: UIViewRepresentable {
|
|||
|
||||
func updateUIView(_ uiView: UITextView, context: Context) {
|
||||
uiView.text = text
|
||||
uiView.isEditable = isEnabled
|
||||
context.coordinator.textView = uiView
|
||||
context.coordinator.text = $text
|
||||
context.coordinator.didChange = textDidChange
|
||||
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
||||
|
@ -100,7 +96,6 @@ struct WrappedTextView: UIViewRepresentable {
|
|||
}
|
||||
|
||||
class Coordinator: NSObject, UITextViewDelegate, ComposeTextViewCaretScrolling {
|
||||
weak var textView: UITextView?
|
||||
var text: Binding<String>
|
||||
var didChange: ((UITextView) -> Void)?
|
||||
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
||||
|
@ -108,16 +103,6 @@ struct WrappedTextView: UIViewRepresentable {
|
|||
init(text: Binding<String>, didChange: ((UITextView) -> Void)?) {
|
||||
self.text = text
|
||||
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) {
|
||||
|
|
|
@ -37,7 +37,7 @@ extension ComposeTextViewCaretScrolling {
|
|||
rectToMakeVisible.origin.y -= cursorRect.height
|
||||
rectToMakeVisible.size.height *= 3
|
||||
|
||||
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) {
|
||||
let animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
|
||||
}
|
||||
self.caretScrollPositionAnimator = animator
|
||||
|
|
|
@ -1,156 +0,0 @@
|
|||
//
|
||||
// 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,7 +15,10 @@ protocol ComposeUIStateDelegate: AnyObject {
|
|||
// @available(iOS, obsoleted: 16.0)
|
||||
func presentAssetPickerSheet()
|
||||
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 {
|
||||
|
@ -24,10 +27,8 @@ class ComposeUIState: ObservableObject {
|
|||
|
||||
@Published var draft: Draft
|
||||
@Published var isShowingSaveDraftSheet = false
|
||||
@Published var isShowingDraftsList = false
|
||||
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
||||
@Published var autocompleteState: AutocompleteState? = nil
|
||||
@Published var isDucking = false
|
||||
|
||||
var composeDrawingMode: ComposeDrawingMode?
|
||||
|
||||
|
|
|
@ -42,13 +42,11 @@ import Combine
|
|||
}
|
||||
|
||||
struct ComposeView: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@ObservedObject var mastodonController: MastodonController
|
||||
@ObservedObject var uiState: ComposeUIState
|
||||
static let coordinateSpaceOutsideOfScrollView = "coordinateSpaceOutsideOfScrollView"
|
||||
|
||||
@State private var globalFrameOutsideList: CGRect = .zero
|
||||
@State private var contentWarningBecomeFirstResponder = false
|
||||
@State private var mainComposeTextViewBecomeFirstResponder = false
|
||||
@ObservedObject var draft: Draft
|
||||
@EnvironmentObject var mastodonController: MastodonController
|
||||
@EnvironmentObject var uiState: ComposeUIState
|
||||
|
||||
@OptionalStateObject private var poster: PostService?
|
||||
@State private var isShowingPostErrorAlert = false
|
||||
|
@ -60,67 +58,42 @@ struct ComposeView: View {
|
|||
|
||||
private let stackPadding: CGFloat = 8
|
||||
|
||||
init(mastodonController: MastodonController, uiState: ComposeUIState) {
|
||||
self.draft = uiState.draft
|
||||
self.mastodonController = mastodonController
|
||||
self.uiState = uiState
|
||||
init(draft: Draft) {
|
||||
self.draft = draft
|
||||
}
|
||||
|
||||
private var charactersRemaining: Int {
|
||||
var charactersRemaining: Int {
|
||||
let limit = mastodonController.instanceFeatures.maxStatusChars
|
||||
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
||||
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: mastodonController.instance))
|
||||
}
|
||||
|
||||
private var requiresAttachmentDescriptions: Bool {
|
||||
var requiresAttachmentDescriptions: Bool {
|
||||
guard Preferences.shared.requireAttachmentDescriptions else { return false }
|
||||
let attachmentIds = draft.attachments.map(\.id)
|
||||
return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) }
|
||||
}
|
||||
|
||||
private var postButtonEnabled: Bool {
|
||||
var postButtonEnabled: Bool {
|
||||
draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions && (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
bodyWithoutEnvironment
|
||||
.environmentObject(uiState)
|
||||
.environmentObject(mastodonController)
|
||||
}
|
||||
|
||||
private var bodyWithoutEnvironment: some View {
|
||||
ZStack(alignment: .top) {
|
||||
mainList
|
||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||
ScrollView(.vertical) {
|
||||
mainStack
|
||||
}
|
||||
.coordinateSpace(name: ComposeView.coordinateSpaceOutsideOfScrollView)
|
||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||
|
||||
if let poster = poster {
|
||||
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
||||
WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
|
||||
}
|
||||
|
||||
autocompleteSuggestions
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.navigationBarTitle("Compose")
|
||||
.actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet)
|
||||
.alert(isPresented: $isShowingPostErrorAlert) {
|
||||
Alert(
|
||||
|
@ -136,67 +109,52 @@ struct ComposeView: View {
|
|||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var autocompleteSuggestions: some View {
|
||||
if let state = uiState.autocompleteState {
|
||||
ComposeAutocompleteView(autocompleteState: state)
|
||||
var autocompleteSuggestions: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
if let state = uiState.autocompleteState {
|
||||
ComposeAutocompleteView(autocompleteState: state)
|
||||
}
|
||||
}
|
||||
.transition(.move(edge: .bottom))
|
||||
.animation(.default, value: uiState.autocompleteState)
|
||||
}
|
||||
|
||||
private var mainList: some View {
|
||||
List {
|
||||
var mainStack: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let id = draft.inReplyToID,
|
||||
let status = mastodonController.persistentContainer.status(for: id) {
|
||||
ComposeReplyView(
|
||||
status: status,
|
||||
rowTopInset: 8,
|
||||
globalFrameOutsideList: globalFrameOutsideList
|
||||
stackPadding: stackPadding
|
||||
)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
|
||||
header
|
||||
.listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
if draft.contentWarningEnabled {
|
||||
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)
|
||||
ComposeEmojiTextField(text: $draft.contentWarning, placeholder: "Write your warning here")
|
||||
}
|
||||
|
||||
MainComposeTextView(
|
||||
draft: draft,
|
||||
becomeFirstResponder: $mainComposeTextViewBecomeFirstResponder
|
||||
draft: draft
|
||||
)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
if let poll = draft.poll {
|
||||
ComposePollView(draft: draft, poll: poll)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.transition(.opacity.combined(with: .asymmetric(insertion: .scale(scale: 0.5, anchor: .leading), removal: .scale(scale: 0.5, anchor: .trailing))))
|
||||
|
||||
}
|
||||
|
||||
ComposeAttachmentsList(
|
||||
draft: draft
|
||||
)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
|
||||
// the list rows provide their own padding, so we cancel out the extra spacing from the VStack
|
||||
.padding([.top, .bottom], -8)
|
||||
}
|
||||
.animation(.default, value: draft.poll?.options.count)
|
||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||
.listStyle(.plain)
|
||||
.disabled(isPosting)
|
||||
.onChange(of: draft.contentWarningEnabled) { newValue in
|
||||
if newValue {
|
||||
contentWarningBecomeFirstResponder = true
|
||||
}
|
||||
}
|
||||
.padding(stackPadding)
|
||||
.padding(.bottom, uiState.autocompleteState != nil ? 46 : nil)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
|
@ -210,15 +168,6 @@ struct ComposeView: View {
|
|||
}.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 {
|
||||
Button(action: self.cancel) {
|
||||
Text("Cancel")
|
||||
|
@ -309,13 +258,6 @@ 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 {
|
||||
// static var previews: some View {
|
||||
// ComposeView()
|
||||
|
|
|
@ -1,127 +0,0 @@
|
|||
//
|
||||
// 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,10 +17,6 @@ struct MainComposeTextView: View {
|
|||
if Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
|
||||
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 {
|
||||
return Text("Do you remember?")
|
||||
} else if components.month == 10 && components.day == 31 {
|
||||
|
@ -35,7 +31,7 @@ struct MainComposeTextView: View {
|
|||
|
||||
let minHeight: CGFloat = 150
|
||||
@State private var height: CGFloat?
|
||||
@Binding var becomeFirstResponder: Bool
|
||||
@State private var becomeFirstResponder: Bool = false
|
||||
@State private var hasFirstAppeared = false
|
||||
@ScaledMetric private var fontSize = 20
|
||||
|
||||
|
@ -48,7 +44,6 @@ struct MainComposeTextView: View {
|
|||
.font(.system(size: fontSize))
|
||||
.foregroundColor(.secondary)
|
||||
.offset(x: 4, y: 8)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
MainComposeWrappedTextView(
|
||||
|
@ -79,7 +74,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
|
||||
@EnvironmentObject var uiState: ComposeUIState
|
||||
@EnvironmentObject var mastodonController: MastodonController
|
||||
@Environment(\.isEnabled) var isEnabled: Bool
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let textView = WrappedTextView()
|
||||
|
@ -100,8 +94,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
uiView.text = text
|
||||
}
|
||||
|
||||
uiView.isEditable = isEnabled
|
||||
|
||||
context.coordinator.text = $text
|
||||
context.coordinator.didChange = textDidChange
|
||||
context.coordinator.uiState = uiState
|
||||
|
@ -172,16 +164,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
self.text = text
|
||||
self.didChange = didChange
|
||||
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) {
|
||||
|
@ -236,11 +218,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
if range.length > 0 {
|
||||
let formatMenu = suggestedActions[index] as! UIMenu
|
||||
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
|
||||
var image: UIImage?
|
||||
if let imageName = fmt.imageName {
|
||||
image = UIImage(systemName: imageName)
|
||||
}
|
||||
return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in
|
||||
UIAction(title: fmt.accessibilityLabel, image: fmt.image) { [weak self] _ in
|
||||
self?.applyFormat(fmt)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -0,0 +1,142 @@
|
|||
//
|
||||
// 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)]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
<?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,8 +70,6 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
|||
|
||||
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(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)
|
||||
}
|
||||
|
||||
|
@ -180,7 +178,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
|||
self.dataSource.apply(snapshot)
|
||||
}
|
||||
|
||||
@objc private func reloadLists() {
|
||||
private func reloadLists() {
|
||||
let request = Client.getLists()
|
||||
mastodonController.run(request) { (response) in
|
||||
guard case let .success(lists, _) = response else {
|
||||
|
@ -198,23 +196,6 @@ 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
|
||||
private func fetchSavedHashtags() -> [SavedHashtag] {
|
||||
let req = SavedHashtag.fetchRequest()
|
||||
|
@ -274,17 +255,29 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
|||
}
|
||||
|
||||
private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) {
|
||||
Task { @MainActor in
|
||||
let service = DeleteListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
|
||||
if await service.run() {
|
||||
var snapshot = dataSource.snapshot()
|
||||
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
|
||||
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)])
|
||||
await dataSource.apply(snapshot)
|
||||
completion(true)
|
||||
} else {
|
||||
completion(false)
|
||||
DispatchQueue.main.async {
|
||||
self.dataSource.apply(snapshot)
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
func removeSavedHashtag(_ hashtag: Hashtag) {
|
||||
|
@ -363,12 +356,28 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
|||
|
||||
case .addList:
|
||||
collectionView.deselectItem(at: indexPath, animated: true)
|
||||
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true) }) { list in
|
||||
let listTimelineController = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
|
||||
listTimelineController.presentEditOnAppear = true
|
||||
self.show(listTimelineController, sender: nil)
|
||||
}
|
||||
service.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(configurationHandler: nil)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "new list alert cancel button"), style: .cancel, handler: nil))
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("Create List", comment: "new list create button"), style: .default, handler: { (_) in
|
||||
guard let title = alert.textFields?.first?.text else {
|
||||
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 {
|
||||
let listTimelineController = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
|
||||
listTimelineController.presentEditOnAppear = true
|
||||
self.show(listTimelineController, sender: nil)
|
||||
}
|
||||
}
|
||||
}))
|
||||
present(alert, animated: true)
|
||||
|
||||
case let .savedHashtag(hashtag):
|
||||
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
|
||||
|
@ -496,7 +505,7 @@ extension ExploreViewController {
|
|||
case (.profileDirectory, .profileDirectory):
|
||||
return true
|
||||
case let (.list(a), .list(b)):
|
||||
return a.id == b.id && a.title == b.title
|
||||
return a.id == b.id
|
||||
case (.addList, .addList):
|
||||
return true
|
||||
case let (.savedHashtag(a), .savedHashtag(b)):
|
||||
|
@ -527,7 +536,6 @@ extension ExploreViewController {
|
|||
case let .list(list):
|
||||
hasher.combine("list")
|
||||
hasher.combine(list.id)
|
||||
hasher.combine(list.title)
|
||||
case .addList:
|
||||
hasher.combine("addList")
|
||||
case let .savedHashtag(hashtag):
|
||||
|
|
|
@ -112,7 +112,7 @@ class ProfileDirectoryViewController: UIViewController {
|
|||
private func updateProfiles() {
|
||||
let scope = self.scope
|
||||
let order = self.order
|
||||
let local = scope == .instance
|
||||
let local = scope == .everywhere
|
||||
let request = Client.getFeaturedProfiles(local: local, order: order)
|
||||
mastodonController.run(request) { (response) in
|
||||
guard case let .success(accounts, _) = response,
|
||||
|
|
|
@ -28,6 +28,8 @@ class FindInstanceViewController: InstanceSelectorTableViewController {
|
|||
super.viewDidLoad()
|
||||
|
||||
delegate = self
|
||||
|
||||
searchController.hidesNavigationBarDuringPresentation = false
|
||||
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed))
|
||||
}
|
||||
|
|
|
@ -13,13 +13,13 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
|||
|
||||
let mastodonController: MastodonController
|
||||
|
||||
private var list: List
|
||||
let list: List
|
||||
|
||||
var dataSource: DataSource!
|
||||
|
||||
var nextRange: RequestRange?
|
||||
|
||||
var searchResultsController: EditListSearchResultsContainerViewController!
|
||||
var searchResultsController: SearchResultsViewController!
|
||||
var searchController: UISearchController!
|
||||
|
||||
init(list: List, mastodonController: MastodonController) {
|
||||
|
@ -28,9 +28,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
|||
|
||||
super.init(style: .plain)
|
||||
|
||||
listChanged()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: list.id)
|
||||
title = String(format: NSLocalizedString("Edit %@", comment: "edit list screen title"), list.title)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
@ -55,23 +53,14 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
|||
})
|
||||
dataSource.editListAccountsController = self
|
||||
|
||||
searchResultsController = EditListSearchResultsContainerViewController(mastodonController: mastodonController) { [unowned self] accountID in
|
||||
Task {
|
||||
await self.addAccount(id: accountID)
|
||||
}
|
||||
}
|
||||
searchResultsController = SearchResultsViewController(mastodonController: mastodonController, resultTypes: [.accounts])
|
||||
searchResultsController.delegate = self
|
||||
searchController = UISearchController(searchResultsController: searchResultsController)
|
||||
searchController.hidesNavigationBarDuringPresentation = false
|
||||
searchController.searchResultsUpdater = searchResultsController
|
||||
if #available(iOS 16.0, *) {
|
||||
searchController.scopeBarActivation = .onSearchActivation
|
||||
} else {
|
||||
searchController.automaticallyShowsScopeBar = true
|
||||
}
|
||||
searchController.searchBar.autocapitalizationType = .none
|
||||
searchController.searchBar.placeholder = NSLocalizedString("Search for accounts to add", comment: "edit list search field placeholder")
|
||||
searchController.searchBar.delegate = searchResultsController
|
||||
searchController.searchBar.scopeButtonTitles = ["Everyone", "People You Follow"]
|
||||
definesPresentationContext = true
|
||||
|
||||
navigationItem.searchController = searchController
|
||||
|
@ -79,76 +68,28 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
|||
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed))
|
||||
|
||||
Task {
|
||||
await loadAccounts()
|
||||
}
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
private func listChanged() {
|
||||
title = String(format: NSLocalizedString("Edit %@", comment: "edit list screen title"), list.title)
|
||||
}
|
||||
|
||||
@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)
|
||||
func loadAccounts() {
|
||||
let request = List.getAccounts(list)
|
||||
mastodonController.run(request) { (response) in
|
||||
guard case let .success(accounts, pagination) = response else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
self.nextRange = pagination?.older
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
mastodonController.persistentContainer.addAll(accounts: accounts) {
|
||||
continuation.resume()
|
||||
self.mastodonController.persistentContainer.addAll(accounts: accounts) {
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
snapshot.deleteSections([.accounts])
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -161,7 +102,24 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
|||
// MARK: - Interaction
|
||||
|
||||
@objc func renameButtonPressed() {
|
||||
RenameListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }).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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -187,8 +145,29 @@ extension EditListAccountsViewController {
|
|||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await self.editListAccountsController?.removeAccount(id: id)
|
||||
let request = List.remove(editListAccountsController!.list, accounts: [id])
|
||||
editListAccountsController!.mastodonController.run(request) { (response) in
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,178 +0,0 @@
|
|||
//
|
||||
// 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 {
|
||||
}
|
|
@ -1,115 +0,0 @@
|
|||
//
|
||||
// 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 {
|
||||
|
||||
private(set) var list: List
|
||||
let list: List
|
||||
|
||||
var presentEditOnAppear = false
|
||||
|
||||
|
@ -20,9 +20,7 @@ class ListTimelineViewController: TimelineViewController {
|
|||
|
||||
super.init(for: .list(id: list.id), mastodonController: mastodonController)
|
||||
|
||||
listChanged()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: list.id)
|
||||
title = list.title
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
|
@ -43,16 +41,6 @@ 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) {
|
||||
let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController)
|
||||
editListAccountsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed))
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
//
|
||||
// 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,8 +99,6 @@ class MainSidebarViewController: UIViewController {
|
|||
|
||||
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(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)
|
||||
|
||||
onViewDidLoad?()
|
||||
|
@ -203,7 +201,7 @@ class MainSidebarViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
@objc private func reloadLists() {
|
||||
private func reloadLists() {
|
||||
let request = Client.getLists()
|
||||
mastodonController.run(request) { [weak self] (response) in
|
||||
guard let self = self, case let .success(lists, _) = response else { return }
|
||||
|
@ -225,23 +223,6 @@ 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
|
||||
private func fetchSavedHashtags() -> [SavedHashtag] {
|
||||
let req = SavedHashtag.fetchRequest()
|
||||
|
@ -316,12 +297,28 @@ class MainSidebarViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
// todo: deduplicate with ExploreViewController
|
||||
private func showAddList() {
|
||||
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true
|
||||
) }) { list in
|
||||
self.sidebarDelegate?.sidebar(self, didSelectItem: .list(list))
|
||||
}
|
||||
service.run()
|
||||
let alert = UIAlertController(title: "New List", message: "Choose a title for your new list", preferredStyle: .alert)
|
||||
alert.addTextField(configurationHandler: nil)
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
|
||||
alert.addAction(UIAlertAction(title: "Create List", style: .default, handler: { (_) in
|
||||
guard let title = alert.textFields?.first?.text else {
|
||||
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
|
||||
|
@ -554,22 +551,11 @@ extension MainSidebarViewController: UICollectionViewDelegate {
|
|||
}
|
||||
activity.displaysAuxiliaryScene = true
|
||||
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) in
|
||||
var actions: [UIAction] = [
|
||||
return UIMenu(children: [
|
||||
UIWindowScene.ActivationAction({ action in
|
||||
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,13 +373,19 @@ fileprivate extension MainSidebarViewController.Item {
|
|||
}
|
||||
}
|
||||
|
||||
extension MainSplitViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
}
|
||||
|
||||
extension MainSplitViewController: TuskerRootViewController {
|
||||
@objc func presentCompose() {
|
||||
self.compose()
|
||||
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
|
||||
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) {
|
||||
|
|
|
@ -228,13 +228,19 @@ extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
extension MainTabBarViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
}
|
||||
|
||||
extension MainTabBarViewController: TuskerRootViewController {
|
||||
@objc func presentCompose() {
|
||||
compose()
|
||||
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
|
||||
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) {
|
||||
|
|
|
@ -1,145 +0,0 @@
|
|||
//
|
||||
// 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,7 +63,6 @@ class InstanceSelectorTableViewController: UITableViewController {
|
|||
appearance.configureWithDefaultBackground()
|
||||
navigationItem.scrollEdgeAppearance = appearance
|
||||
|
||||
tableView.keyboardDismissMode = .interactive
|
||||
tableView.register(UINib(nibName: "InstanceTableViewCell", bundle: .main), forCellReuseIdentifier: instanceCell)
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 120
|
||||
|
@ -85,11 +84,7 @@ class InstanceSelectorTableViewController: UITableViewController {
|
|||
searchController = UISearchController(searchResultsController: nil)
|
||||
searchController.searchResultsUpdater = self
|
||||
searchController.obscuresBackgroundDuringPresentation = false
|
||||
searchController.hidesNavigationBarDuringPresentation = false
|
||||
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.hidesSearchBarWhenScrolling = false
|
||||
if #available(iOS 16.0, *) {
|
||||
|
|
|
@ -31,7 +31,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
|||
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
private(set) var headerCell: ProfileHeaderCollectionViewCell?
|
||||
|
||||
private(set) var state: State = .unloaded
|
||||
private var state: State = .unloaded
|
||||
|
||||
init(accountID: String?, kind: Kind, owner: ProfileViewController) {
|
||||
self.accountID = accountID
|
||||
|
@ -99,6 +99,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
|||
Task {
|
||||
await load()
|
||||
}
|
||||
case .loading:
|
||||
break
|
||||
case .loaded, .setupInitialSnapshot:
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.reconfigureItems([.header(id)])
|
||||
|
@ -131,8 +133,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
|||
view.updateUI(for: id)
|
||||
view.pagesSegmentedControl.selectedSegmentIndex = self.owner?.currentIndex ?? 0
|
||||
cell.addHeader(view)
|
||||
case .useExistingView(let view):
|
||||
cell.addHeader(view)
|
||||
case .placeholder(height: let height):
|
||||
_ = cell.addConstraint(height: height)
|
||||
}
|
||||
|
@ -174,11 +174,13 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
|||
private func load() async {
|
||||
guard isViewLoaded,
|
||||
let accountID,
|
||||
state == .unloaded,
|
||||
case .unloaded = state,
|
||||
mastodonController.persistentContainer.account(for: accountID) != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
state = .loading
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.header, .pinned, .statuses])
|
||||
snapshot.appendItems([.header(accountID)], toSection: .header)
|
||||
|
@ -190,9 +192,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
|||
await tryLoadPinned()
|
||||
|
||||
state = .loaded
|
||||
|
||||
// remove any content inset that was added when switching pages to this VC
|
||||
collectionView.contentInset = .zero
|
||||
}
|
||||
|
||||
private func tryLoadPinned() async {
|
||||
|
@ -261,6 +260,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
|||
extension ProfileStatusesViewController {
|
||||
enum State {
|
||||
case unloaded
|
||||
case loading
|
||||
case setupInitialSnapshot
|
||||
case loaded
|
||||
}
|
||||
|
@ -271,7 +271,7 @@ extension ProfileStatusesViewController {
|
|||
case statuses, withReplies, onlyMedia
|
||||
}
|
||||
enum HeaderMode {
|
||||
case createView, useExistingView(ProfileHeaderView), placeholder(height: CGFloat)
|
||||
case createView, placeholder(height: CGFloat)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import UIKit
|
|||
import Pachyderm
|
||||
import Combine
|
||||
|
||||
class ProfileViewController: UIViewController {
|
||||
class ProfileViewController: UIPageViewController {
|
||||
|
||||
weak var mastodonController: MastodonController!
|
||||
|
||||
|
@ -42,7 +42,7 @@ class ProfileViewController: UIViewController {
|
|||
self.accountID = accountID
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
||||
|
||||
self.pageControllers = [
|
||||
.init(accountID: accountID, kind: .statuses, owner: self),
|
||||
|
@ -146,32 +146,26 @@ class ProfileViewController: UIViewController {
|
|||
|
||||
state = .animating
|
||||
|
||||
let new = pageControllers[index]
|
||||
let direction: UIPageViewController.NavigationDirection
|
||||
if currentIndex == nil || index - currentIndex > 0 {
|
||||
direction = .forward
|
||||
} else {
|
||||
direction = .reverse
|
||||
}
|
||||
|
||||
guard let currentIndex else {
|
||||
assert(!animated)
|
||||
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
|
||||
new.initialHeaderMode = .createView
|
||||
new.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
embedChild(new)
|
||||
self.currentIndex = index
|
||||
state = .idle
|
||||
completion?(true)
|
||||
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 direction: CGFloat
|
||||
if index - currentIndex > 0 {
|
||||
direction = 1 // forward
|
||||
} else {
|
||||
direction = -1 // reverse
|
||||
}
|
||||
|
||||
let old = pageControllers[currentIndex]
|
||||
|
||||
new.loadViewIfNeeded()
|
||||
|
||||
self.currentIndex = index
|
||||
currentIndex = index
|
||||
|
||||
// TODO: old.headerCell could be nil if scrolled down and key command used
|
||||
let oldHeaderCell = old.headerCell!
|
||||
|
@ -179,8 +173,8 @@ class ProfileViewController: UIViewController {
|
|||
// old header cell must have the header view
|
||||
let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)!
|
||||
|
||||
if let newHeaderCell = new.headerCell {
|
||||
_ = newHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)
|
||||
if new.isViewLoaded {
|
||||
_ = new.headerCell!.addConstraint(height: oldHeaderCell.bounds.height)
|
||||
} else {
|
||||
new.initialHeaderMode = .placeholder(height: oldHeaderCell.bounds.height)
|
||||
}
|
||||
|
@ -201,66 +195,60 @@ class ProfileViewController: UIViewController {
|
|||
// hide scroll indicators during the transition because otherwise the show through the
|
||||
// profile header, even though it has an opaque background
|
||||
old.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 new.isViewLoaded {
|
||||
new.collectionView.showsVerticalScrollIndicator = false
|
||||
}
|
||||
|
||||
if animated {
|
||||
// if the new view isn't tall enough to match content offsets
|
||||
if new.collectionView.contentSize.height - new.collectionView.bounds.height < old.collectionView.contentOffset.y {
|
||||
let additionalHeightNeededToMatchContentOffset = old.collectionView.contentOffset.y + old.collectionView.bounds.height - new.collectionView.contentSize.height
|
||||
new.collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: additionalHeightNeededToMatchContentOffset, right: 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,
|
||||
!new.isViewLoaded || 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
|
||||
// results in the collection view immediately removing cells that will be offscreen.
|
||||
// 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)
|
||||
|
||||
new.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
embedChild(new)
|
||||
new.view.transform = CGAffineTransform(translationX: direction * view.bounds.width, y: yTranslationToMatchOldContentOffset)
|
||||
snapshot.frame = old.collectionView.bounds
|
||||
snapshot.frame.origin.y = 0
|
||||
snapshot.layer.zPosition = 99
|
||||
view.addSubview(snapshot)
|
||||
|
||||
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
|
||||
|
||||
new.collectionView.transform = .identity
|
||||
new.collectionView.contentOffset = origOldContentOffset
|
||||
|
||||
// reenable scroll indicators after the switching animation is done
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
|
||||
self.state = .idle
|
||||
completion?(true)
|
||||
headerView.transform = CGAffineTransform(translationX: 0, y: -headerTopOffset)
|
||||
} completion: { _ in
|
||||
snapshot.removeFromSuperview()
|
||||
}
|
||||
animator.startAnimation()
|
||||
} else {
|
||||
old.removeViewAndController()
|
||||
new.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
embedChild(new)
|
||||
completion?(true)
|
||||
} else if new.isViewLoaded {
|
||||
new.collectionView.contentOffset = old.collectionView.contentOffset
|
||||
}
|
||||
|
||||
setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in
|
||||
// reenable scroll indicators after the switching animation is done
|
||||
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
|
||||
// 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
|
||||
completion?(finished)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -197,11 +197,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
|
||||
@objc func refresh() {
|
||||
Task {
|
||||
if case .notLoadedInitial = await controller.state {
|
||||
await controller.loadInitial()
|
||||
} else {
|
||||
await controller.loadNewer()
|
||||
}
|
||||
await controller.loadNewer()
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
collectionView.refreshControl?.endRefreshing()
|
||||
#endif
|
||||
|
@ -284,8 +280,6 @@ extension TimelineViewController {
|
|||
typealias TimelineItem = String // status ID
|
||||
|
||||
func loadInitial() async throws -> [TimelineItem] {
|
||||
try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
|
||||
|
||||
guard let mastodonController else {
|
||||
throw Error.noClient
|
||||
}
|
||||
|
|
|
@ -76,7 +76,6 @@ class CustomAlertController: UIViewController {
|
|||
let titleLabel = UILabel()
|
||||
titleLabel.text = config.title
|
||||
titleLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0)
|
||||
titleLabel.adjustsFontForContentSizeCategory = true
|
||||
titleLabel.numberOfLines = 0
|
||||
titleLabel.textAlignment = .center
|
||||
stack.addArrangedSubview(titleLabel)
|
||||
|
@ -362,14 +361,13 @@ class CustomAlertActionButton: UIControl {
|
|||
let label = UILabel()
|
||||
label.text = title
|
||||
label.textColor = .tintColor
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
if case .cancel = action.style {
|
||||
switch action.style {
|
||||
case .cancel:
|
||||
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0)
|
||||
} else {
|
||||
label.font = .preferredFont(forTextStyle: .body)
|
||||
}
|
||||
if case .destructive = action.style {
|
||||
case .destructive:
|
||||
label.textColor = .systemRed
|
||||
default:
|
||||
break
|
||||
}
|
||||
titleView.addArrangedSubview(label)
|
||||
}
|
||||
|
|
|
@ -10,7 +10,6 @@ import UIKit
|
|||
import SafariServices
|
||||
import Pachyderm
|
||||
import WebURLFoundationExtras
|
||||
import SwiftUI
|
||||
|
||||
protocol MenuActionProvider: AnyObject {
|
||||
var navigationDelegate: TuskerNavigationDelegate? { get }
|
||||
|
@ -43,6 +42,46 @@ extension MenuActionProvider {
|
|||
guard let mastodonController = mastodonController,
|
||||
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 = [
|
||||
openInSafariAction(url: account.url),
|
||||
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
|
||||
|
@ -51,33 +90,11 @@ 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))
|
||||
|
||||
return [
|
||||
UIMenu(options: .displayInline, children: shareSection),
|
||||
UIMenu(options: .displayInline, children: actionsSection),
|
||||
UIMenu(options: .displayInline, children: suppressSection),
|
||||
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
|
||||
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: actionsSection),
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -149,7 +166,12 @@ extension MenuActionProvider {
|
|||
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
|
||||
|
||||
case .failure(let error):
|
||||
self.handleError(error, title: "Error \(bookmarked ? "Unb" : "B")ookmarking")
|
||||
if let toastable = self.toastableViewController {
|
||||
let config = ToastConfiguration(from: error, with: "Error \(bookmarked ? "Unb" : "B")ookmarking", in: toastable, retryAction: nil)
|
||||
DispatchQueue.main.async {
|
||||
toastable.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
@ -205,7 +227,12 @@ extension MenuActionProvider {
|
|||
case .success(let status, _):
|
||||
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
|
||||
case .failure(let error):
|
||||
self.handleError(error, title: "Error \(muted ? "Unm" : "M")uting")
|
||||
if let toastable = self.toastableViewController {
|
||||
let config = ToastConfiguration(from: error, with: "Error \(muted ? "Unm" : "M")uting", in: toastable, retryAction: nil)
|
||||
DispatchQueue.main.async {
|
||||
toastable.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
@ -224,7 +251,12 @@ extension MenuActionProvider {
|
|||
case .success(let status, _):
|
||||
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
|
||||
case .failure(let error):
|
||||
self.handleError(error, title: "Error \(pinned ? "Unp" :"P")inning")
|
||||
if let toastable = self.toastableViewController {
|
||||
let config = ToastConfiguration(from: error, with: "Error \(pinned ? "Unp" :"P")inning", in: toastable, retryAction: nil)
|
||||
DispatchQueue.main.async {
|
||||
toastable.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
@ -244,7 +276,12 @@ extension MenuActionProvider {
|
|||
mastodonController.persistentContainer.addOrUpdate(status: status, context: mastodonController.persistentContainer.viewContext)
|
||||
}
|
||||
case .failure(let error):
|
||||
self?.handleError(error, title: "Error Refreshing Poll")
|
||||
if let toastable = self?.toastableViewController {
|
||||
let config = ToastConfiguration(from: error, with: "Error Refreshing Poll", in: toastable, retryAction: nil)
|
||||
DispatchQueue.main.async {
|
||||
toastable.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}), at: 0)
|
||||
|
@ -330,46 +367,25 @@ extension MenuActionProvider {
|
|||
})
|
||||
}
|
||||
|
||||
private func handleError(_ error: Client.Error, title: String) {
|
||||
if let toastable = self.toastableViewController {
|
||||
let config = ToastConfiguration(from: error, with: title, in: toastable, retryAction: nil)
|
||||
DispatchQueue.main.async {
|
||||
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 {
|
||||
private func followAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement? {
|
||||
guard let ownAccount = mastodonController.account,
|
||||
relationship.accountID != ownAccount.id else {
|
||||
return nil
|
||||
}
|
||||
let accountID = relationship.accountID
|
||||
let following = relationship.following
|
||||
return createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus") { [weak self] _ in
|
||||
return createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus") { _ in
|
||||
let request = (following ? Account.unfollow : Account.follow)(accountID)
|
||||
mastodonController.run(request) { response in
|
||||
switch response {
|
||||
case .failure(let error):
|
||||
self?.handleError(error, title: "Error \(following ? "Unf" : "F")ollowing")
|
||||
if let toastable = self.toastableViewController {
|
||||
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, _):
|
||||
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
||||
}
|
||||
|
@ -377,71 +393,6 @@ 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? {
|
||||
|
|
|
@ -64,14 +64,6 @@ extension TimelineLikeCollectionViewController {
|
|||
}
|
||||
|
||||
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()
|
||||
if !snapshot.sectionIdentifiers.contains(.footer) {
|
||||
snapshot.appendSections([.footer])
|
||||
|
@ -85,13 +77,6 @@ extension TimelineLikeCollectionViewController {
|
|||
}
|
||||
|
||||
func handleRemoveLoadingIndicator() async {
|
||||
if case .loadingInitial(_, _) = await controller.state,
|
||||
let refreshControl = collectionView.refreshControl,
|
||||
refreshControl.isRefreshing {
|
||||
refreshControl.endRefreshing()
|
||||
return
|
||||
}
|
||||
|
||||
let oldContentOffset = collectionView.contentOffset
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.deleteSections([.footer])
|
||||
|
|
|
@ -70,7 +70,7 @@ actor TimelineLikeController<Item> {
|
|||
} catch {
|
||||
await loadingIndicator.end()
|
||||
await emit(event: .loadAllError(error, token))
|
||||
state = .notLoadedInitial
|
||||
state = .idle
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -194,7 +194,7 @@ actor TimelineLikeController<Item> {
|
|||
return false
|
||||
}
|
||||
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
||||
return to == .notLoadedInitial || to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true))
|
||||
return to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true))
|
||||
case .loadingNewer(_):
|
||||
return to == .idle
|
||||
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
||||
|
|
|
@ -96,15 +96,9 @@ extension TuskerNavigationDelegate {
|
|||
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil)
|
||||
} else {
|
||||
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
||||
if #available(iOS 16.0, *),
|
||||
presentDuckable(compose) {
|
||||
return
|
||||
} else {
|
||||
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
||||
let nav = UINavigationController(rootViewController: compose)
|
||||
nav.presentationController?.delegate = compose
|
||||
present(nav, animated: true)
|
||||
}
|
||||
let nav = UINavigationController(rootViewController: compose)
|
||||
nav.presentationController?.delegate = compose
|
||||
present(nav, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,9 +11,11 @@ import Foundation
|
|||
struct ViewTags {
|
||||
private init() {}
|
||||
|
||||
static let navBackBarButton = 42001
|
||||
static let navForwardBarButton = 42002
|
||||
static let navEmptyTitleView = 42003
|
||||
static let splitNavCloseSecondaryButton = 42004
|
||||
static let customAlertSeparator = 42005
|
||||
static let composeVisibilityBarButton = 42001
|
||||
static let composeLocalOnlyBarButton = 42002
|
||||
static let navBackBarButton = 42003
|
||||
static let navForwardBarButton = 42004
|
||||
static let navEmptyTitleView = 42005
|
||||
static let splitNavCloseSecondaryButton = 42006
|
||||
static let customAlertSeparator = 42007
|
||||
}
|
||||
|
|
|
@ -69,7 +69,6 @@ class AccountTableViewCell: UITableViewCell {
|
|||
|
||||
let accountID = self.accountID
|
||||
|
||||
avatarImageView.image = nil
|
||||
if let avatarURL = account.avatar {
|
||||
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
|
||||
guard let self = self else { return }
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
//
|
||||
// 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,7 +55,6 @@ class ConfirmReblogStatusPreviewView: UIView {
|
|||
let displayNameLabel = EmojiLabel()
|
||||
displayNameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .caption1).addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold]]), size: 0)
|
||||
displayNameLabel.adjustsFontSizeToFitWidth = true
|
||||
displayNameLabel.adjustsFontForContentSizeCategory = true
|
||||
displayNameLabel.updateForAccountDisplayName(account: status.account)
|
||||
vStack.addArrangedSubview(displayNameLabel)
|
||||
|
||||
|
@ -65,7 +64,6 @@ class ConfirmReblogStatusPreviewView: UIView {
|
|||
contentView.isScrollEnabled = false
|
||||
contentView.backgroundColor = nil
|
||||
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
|
||||
contentView.paragraphStyle = .default
|
||||
// TODO: line limit
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
//
|
||||
// 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() }
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
<?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>
|
|
@ -1,98 +0,0 @@
|
|||
//
|
||||
// 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,14 +18,7 @@ class ProfileFieldsView: UIView {
|
|||
|
||||
private var isUsingSingleColumn: Bool = false
|
||||
private var needsSingleColumn: Bool {
|
||||
traitCollection.horizontalSizeClass == .compact && traitCollection.preferredContentSizeCategory > .extraLarge
|
||||
}
|
||||
|
||||
override var accessibilityElements: [Any]? {
|
||||
get {
|
||||
fieldViews.flatMap { [$0.0, $0.1] }
|
||||
}
|
||||
set {}
|
||||
traitCollection.preferredContentSizeCategory > .large
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
|
|
@ -142,14 +142,14 @@ class ProfileHeaderView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
fieldsView.delegate = delegate
|
||||
fieldsView.updateUI(account: account)
|
||||
|
||||
accessibilityElements = [
|
||||
displayNameLabel!,
|
||||
usernameLabel!,
|
||||
noteTextView!,
|
||||
fieldsView!,
|
||||
// TODO: voiceover for fieldsview
|
||||
// fieldsView!,
|
||||
moreButton!,
|
||||
pagesSegmentedControl!,
|
||||
]
|
||||
|
|
|
@ -321,67 +321,32 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
|
||||
// MARK: Accessibility
|
||||
|
||||
override var isAccessibilityElement: Bool {
|
||||
get { true }
|
||||
set {}
|
||||
}
|
||||
|
||||
override var accessibilityAttributedLabel: NSAttributedString? {
|
||||
override var accessibilityLabel: String? {
|
||||
get {
|
||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
var str = "\(status.account.displayOrUserName), \(contentTextView.text ?? "")"
|
||||
|
||||
if status.attachments.count > 0 {
|
||||
// TODO: localize me
|
||||
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")")
|
||||
str += ", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")"
|
||||
}
|
||||
if status.poll != nil {
|
||||
str += ", poll"
|
||||
}
|
||||
str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))")
|
||||
if status.visibility < .unlisted {
|
||||
str += AttributedString(", \(status.visibility.displayName)")
|
||||
}
|
||||
if status.localOnly {
|
||||
str += ", local"
|
||||
}
|
||||
str += ", \(status.createdAt.formatted(.relative(presentation: .numeric)))"
|
||||
if let rebloggerID,
|
||||
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
|
||||
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
|
||||
str += ", reblogged by \(reblogger.displayOrUserName)"
|
||||
}
|
||||
return str
|
||||
}
|
||||
set {}
|
||||
}
|
||||
|
||||
override func accessibilityActivate() -> Bool {
|
||||
if statusState.collapsed ?? false {
|
||||
toggleCollapse()
|
||||
} else {
|
||||
delegate?.selected(status: statusID, state: statusState.copy())
|
||||
}
|
||||
delegate?.selected(status: statusID, state: statusState.copy())
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
@ -249,62 +249,33 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
|||
|
||||
// MARK: - Accessibility
|
||||
|
||||
override var accessibilityAttributedLabel: NSAttributedString? {
|
||||
override var accessibilityLabel: String? {
|
||||
get {
|
||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
var str = "\(status.account.displayName), \(contentTextView.text ?? "")"
|
||||
|
||||
if status.attachments.count > 0 {
|
||||
// TODO: localize me
|
||||
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")")
|
||||
// todo: localize me
|
||||
str += ", \(status.attachments.count) attachments"
|
||||
}
|
||||
if status.poll != nil {
|
||||
str += ", poll"
|
||||
}
|
||||
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,
|
||||
str += ", \(TimelineStatusTableViewCell.relativeDateFormatter.localizedString(for: status.createdAt, relativeTo: Date()))"
|
||||
if let rebloggerID = rebloggerID,
|
||||
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
|
||||
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
|
||||
str += ", reblogged by \(reblogger.displayName)"
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
set {}
|
||||
}
|
||||
|
||||
override func accessibilityActivate() -> Bool {
|
||||
if statusState.collapsed ?? false {
|
||||
collapseButtonPressed()
|
||||
} else {
|
||||
didSelectCell()
|
||||
}
|
||||
didSelectCell()
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue