Compare commits

..

No commits in common. "dd82283341347db88ed49bb7a1545c8193af0722" and "68c3affacfbc6dbebeae5b65eec28671f1cb6649" have entirely different histories.

80 changed files with 1253 additions and 2697 deletions

View File

@ -1,44 +1,5 @@
# Changelog # Changelog
## 2022.1 (45)
Features/Improvements:
- iPhone: Temporarily hide the Compose screen by swiping down to access the rest of the applies
- Add Block, Domain Block, and Mute actions to accounts
- Don't change scroll position when switch sections in the Profile screen
- Use URL keyboard in the instance selector and clarify that you can enter any domain
- iPad: Add context menu action for deleting lists in sidebar
- Tweak conditions in which profile fields are shown in a single column, rather than two
- Convert wide color gamut images to sRGB before uploading
- The Mastodon backend does not support wide-gamut images and does a poor job of conversion, so the conversion is performed locally
- Focus content warning field immediately when CW button is pressed
- Move focus to main text field when return key is pressed while editing the content warning
- Make GIF attachments animate on the Compose screen
- VoiceOver: Make profile fields accessible
- VoiceOver: Only read content warning and not content for CW'd posts
- VoiceOver: Expand collapsed posts when performing double-tap
- VoiceOver: Announce visibility of followers-only & direct posts
- VoiceOver: Make Compose toolbar accessible
Bugfixes:
- Fix tapping links in profile fields
- Fix crash when creating/editing list fails
- Fix renaming a list not updating it elsewhere in the UI
- Fix instance-local/everywhere scope selector in Profile Directory being flipped
- Fix context menu previews of attachments not working
- Fix caret not scrolling into view when opening Compose
- Fix cells in the Drafts list being too small to tap
- Fix refresh failing when initial load failed
- Fix video controls in the gallery being too close to the edge of the screen
- Fix error when decoding malformed notifications
- Fix reblog with visibility not being available on Hometown instances
- Fix visibility dropdown being shown in confirm reblog alert even when unavailable
- Fix confirm reblog alert not adjusting to Dynamic Type
- Fix layout issues with replies on Compose screen
- macOS: Fix GIFs dragged from Finder posting static images
Known Issues:
- Drag/drop to add attachments when composting a post does not work
## 2022.1 (44) ## 2022.1 (44)
Features/Improvements: Features/Improvements:
- Dynamic Type support - Dynamic Type support

View File

@ -82,14 +82,14 @@ public final class Account: AccountProtocol, Decodable {
return Request<Empty>(method: .delete, path: "/api/v1/suggestions/\(account.id)") return Request<Empty>(method: .delete, path: "/api/v1/suggestions/\(account.id)")
} }
public static func getFollowers(_ accountID: String, range: RequestRange = .default) -> Request<[Account]> { public static func getFollowers(_ account: Account, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(accountID)/followers") var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(account.id)/followers")
request.range = range request.range = range
return request return request
} }
public static func getFollowing(_ accountID: String, range: RequestRange = .default) -> Request<[Account]> { public static func getFollowing(_ account: Account, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(accountID)/following") var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(account.id)/following")
request.range = range request.range = range
return request return request
} }
@ -112,22 +112,22 @@ public final class Account: AccountProtocol, Decodable {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unfollow") return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unfollow")
} }
public static func block(_ accountID: String) -> Request<Relationship> { public static func block(_ account: Account) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/block") return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/block")
} }
public static func unblock(_ accountID: String) -> Request<Relationship> { public static func unblock(_ account: Account) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unblock") return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/unblock")
} }
public static func mute(_ accountID: String, notifications: Bool? = nil) -> Request<Relationship> { public static func mute(_ account: Account, notifications: Bool? = nil) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/mute", body: ParametersBody([ return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: ParametersBody([
"notifications" => notifications "notifications" => notifications
])) ]))
} }
public static func unmute(_ accountID: String) -> Request<Relationship> { public static func unmute(_ account: Account) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unmute") return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/unmute")
} }
public static func getLists(_ account: Account) -> Request<[List]> { public static func getLists(_ account: Account) -> Request<[List]> {

View File

@ -30,7 +30,11 @@ public class Notification: Decodable {
} }
self.createdAt = try container.decode(Date.self, forKey: .createdAt) self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.account = try container.decode(Account.self, forKey: .account) self.account = try container.decode(Account.self, forKey: .account)
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> { public static func dismiss(id notificationID: String) -> Request<Empty> {

View File

@ -1,9 +0,0 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -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"]),
]
)

View File

@ -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).

View File

@ -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
}
}

View File

@ -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")
}

View File

@ -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)
}
}
}

View File

@ -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()
}
}
}

View File

@ -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()
}
}

View File

@ -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")
}
}

View File

@ -76,6 +76,10 @@
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */; }; D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */; };
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */; }; D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */; };
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627FF75217E923E00CC0648 /* DraftsManager.swift */; }; D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627FF75217E923E00CC0648 /* DraftsManager.swift */; };
D627FF79217E950100CC0648 /* DraftsTableViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D627FF78217E950100CC0648 /* DraftsTableViewController.xib */; };
D627FF7B217E951500CC0648 /* DraftsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627FF7A217E951500CC0648 /* DraftsTableViewController.swift */; };
D627FF7D217E958900CC0648 /* DraftTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D627FF7C217E958900CC0648 /* DraftTableViewCell.xib */; };
D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627FF7E217E95E000CC0648 /* DraftTableViewCell.swift */; };
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6285B5221EA708700FE4B39 /* StatusFormat.swift */; }; D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6285B5221EA708700FE4B39 /* StatusFormat.swift */; };
D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6289E83217B795D0003D1D7 /* LargeImageViewController.xib */; }; D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6289E83217B795D0003D1D7 /* LargeImageViewController.xib */; };
D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2421217AA7E1005076CC /* UserActivityManager.swift */; }; D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2421217AA7E1005076CC /* UserActivityManager.swift */; };
@ -148,7 +152,6 @@
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; }; D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */; };
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; }; D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; };
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; }; D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; };
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */; };
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; }; D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; };
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; }; D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284724ECBCB100C732D3 /* ComposeView.swift */; };
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; }; D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */; };
@ -251,10 +254,6 @@
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */; }; D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */; };
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; }; D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; };
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; }; D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; };
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; };
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; };
D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA248291C6118002F4D01 /* DraftsView.swift */; };
D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */; };
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; }; D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; };
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; }; D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; };
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; }; D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
@ -271,6 +270,7 @@
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; }; D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; }; D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; };
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; }; D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; };
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */; };
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; }; D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; };
D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; }; D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; };
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */; }; D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */; };
@ -309,13 +309,6 @@
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; }; D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; }; D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; };
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; }; D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; };
D6F6A54C291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54B291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift */; };
D6F6A54E291EF7E100F496A8 /* EditListSearchFollowingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54D291EF7E100F496A8 /* EditListSearchFollowingViewController.swift */; };
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54F291F058600F496A8 /* CreateListService.swift */; };
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A551291F098700F496A8 /* RenameListService.swift */; };
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A553291F0D9600F496A8 /* DeleteListService.swift */; };
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */; };
D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A5582920676800F496A8 /* ComposeToolbar.swift */; };
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; }; D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; };
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; }; D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -436,6 +429,10 @@
D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTimelineViewController.swift; sourceTree = "<group>"; }; D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTimelineViewController.swift; sourceTree = "<group>"; };
D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListAccountsViewController.swift; sourceTree = "<group>"; }; D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListAccountsViewController.swift; sourceTree = "<group>"; };
D627FF75217E923E00CC0648 /* DraftsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsManager.swift; sourceTree = "<group>"; }; D627FF75217E923E00CC0648 /* DraftsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsManager.swift; sourceTree = "<group>"; };
D627FF78217E950100CC0648 /* DraftsTableViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = DraftsTableViewController.xib; sourceTree = "<group>"; };
D627FF7A217E951500CC0648 /* DraftsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsTableViewController.swift; sourceTree = "<group>"; };
D627FF7C217E958900CC0648 /* DraftTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = DraftTableViewCell.xib; sourceTree = "<group>"; };
D627FF7E217E95E000CC0648 /* DraftTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftTableViewCell.swift; sourceTree = "<group>"; };
D6285B5221EA708700FE4B39 /* StatusFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFormat.swift; sourceTree = "<group>"; }; D6285B5221EA708700FE4B39 /* StatusFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFormat.swift; sourceTree = "<group>"; };
D6289E83217B795D0003D1D7 /* LargeImageViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LargeImageViewController.xib; sourceTree = "<group>"; }; D6289E83217B795D0003D1D7 /* LargeImageViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LargeImageViewController.xib; sourceTree = "<group>"; };
D62D2421217AA7E1005076CC /* UserActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityManager.swift; sourceTree = "<group>"; }; D62D2421217AA7E1005076CC /* UserActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityManager.swift; sourceTree = "<group>"; };
@ -510,7 +507,6 @@
D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; }; D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; };
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; }; D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; }; D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; };
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPicker.swift; sourceTree = "<group>"; };
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; sourceTree = "<group>"; }; D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; sourceTree = "<group>"; };
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; }; D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; }; D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
@ -613,10 +609,6 @@
D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsMode.swift; sourceTree = "<group>"; }; D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsMode.swift; sourceTree = "<group>"; };
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; }; D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; };
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; }; D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; };
D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = "<group>"; };
D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = "<group>"; };
D6BEA248291C6118002F4D01 /* DraftsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsView.swift; sourceTree = "<group>"; };
D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertWithData.swift; sourceTree = "<group>"; };
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; }; D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = "<group>"; }; D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = "<group>"; };
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; }; D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
@ -633,6 +625,7 @@
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; }; D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; }; D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = "<group>"; }; D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = "<group>"; };
D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeContainerView.swift; sourceTree = "<group>"; };
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = "<group>"; }; D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = "<group>"; };
D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = "<group>"; }; D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = "<group>"; };
D6D4DDCC212518A000E1C4BB /* Tusker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tusker.app; sourceTree = BUILT_PRODUCTS_DIR; }; D6D4DDCC212518A000E1C4BB /* Tusker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tusker.app; sourceTree = BUILT_PRODUCTS_DIR; };
@ -680,13 +673,6 @@
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; }; D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; };
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; }; D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; };
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = "<group>"; }; D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = "<group>"; };
D6F6A54B291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListSearchResultsContainerViewController.swift; sourceTree = "<group>"; };
D6F6A54D291EF7E100F496A8 /* EditListSearchFollowingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListSearchFollowingViewController.swift; sourceTree = "<group>"; };
D6F6A54F291F058600F496A8 /* CreateListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateListService.swift; sourceTree = "<group>"; };
D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = "<group>"; };
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteListService.swift; sourceTree = "<group>"; };
D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MuteAccountView.swift; sourceTree = "<group>"; };
D6F6A5582920676800F496A8 /* ComposeToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbar.swift; sourceTree = "<group>"; };
D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = "<group>"; }; D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = "<group>"; };
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = "<group>"; }; D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -701,7 +687,6 @@
D6552367289870790048A653 /* ScreenCorners in Frameworks */, D6552367289870790048A653 /* ScreenCorners in Frameworks */,
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */, D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */, D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -751,6 +736,15 @@
path = "Hashtag Cell"; path = "Hashtag Cell";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D61959D0241E842400A37B8E /* Draft Cell */ = {
isa = PBXGroup;
children = (
D627FF7C217E958900CC0648 /* DraftTableViewCell.xib */,
D627FF7E217E95E000CC0648 /* DraftTableViewCell.swift */,
);
path = "Draft Cell";
sourceTree = "<group>";
};
D61959D2241E846D00A37B8E /* Models */ = { D61959D2241E846D00A37B8E /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -845,12 +839,19 @@
children = ( children = (
D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */, D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */,
D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */, D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */,
D6F6A54B291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift */,
D6F6A54D291EF7E100F496A8 /* EditListSearchFollowingViewController.swift */,
); );
path = Lists; path = Lists;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D627FF77217E94F200CC0648 /* Drafts */ = {
isa = PBXGroup;
children = (
D627FF78217E950100CC0648 /* DraftsTableViewController.xib */,
D627FF7A217E951500CC0648 /* DraftsTableViewController.swift */,
);
path = Drafts;
sourceTree = "<group>";
};
D62D241E217AA46B005076CC /* Shortcuts */ = { D62D241E217AA46B005076CC /* Shortcuts */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -898,12 +899,12 @@
D641C787213DD862004B4513 /* Compose */, D641C787213DD862004B4513 /* Compose */,
D641C785213DD83B004B4513 /* Conversation */, D641C785213DD83B004B4513 /* Conversation */,
D6F2E960249E772F005846BB /* Crash Reporter */, D6F2E960249E772F005846BB /* Crash Reporter */,
D627FF77217E94F200CC0648 /* Drafts */,
D627943C23A5635D00D38C68 /* Explore */, D627943C23A5635D00D38C68 /* Explore */,
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */, D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
D641C788213DD86D004B4513 /* Large Image */, D641C788213DD86D004B4513 /* Large Image */,
D627944B23A9A02400D38C68 /* Lists */, D627944B23A9A02400D38C68 /* Lists */,
D641C782213DD7F0004B4513 /* Main */, D641C782213DD7F0004B4513 /* Main */,
D6F6A555291F4F0C00F496A8 /* Mute */,
D641C786213DD852004B4513 /* Notifications */, D641C786213DD852004B4513 /* Notifications */,
D641C783213DD7FE004B4513 /* Onboarding */, D641C783213DD7FE004B4513 /* Onboarding */,
D641C789213DD87E004B4513 /* Preferences */, D641C789213DD87E004B4513 /* Preferences */,
@ -932,7 +933,6 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */, D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */,
D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */,
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */, D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */,
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */, 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */,
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */, D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */,
@ -987,6 +987,7 @@
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */, D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */,
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */, D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */,
D622759F24F1677200B82A16 /* ComposeHostingController.swift */, D622759F24F1677200B82A16 /* ComposeHostingController.swift */,
D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */,
D677284724ECBCB100C732D3 /* ComposeView.swift */, D677284724ECBCB100C732D3 /* ComposeView.swift */,
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */, D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */,
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */, D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */,
@ -1002,8 +1003,6 @@
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */, D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */,
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */, D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */,
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */, D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */,
D6F6A5582920676800F496A8 /* ComposeToolbar.swift */,
D6BEA248291C6118002F4D01 /* DraftsView.swift */,
); );
path = Compose; path = Compose;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1277,7 +1276,6 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */, D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D6BEA24A291C6A2B002F4D01 /* AlertWithData.swift */,
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */, D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */, D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */, D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
@ -1291,7 +1289,6 @@
D620483323D3801D008A63EF /* LinkTextView.swift */, D620483323D3801D008A63EF /* LinkTextView.swift */,
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */, D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */,
D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */, D6262C9928D01C4B00390C1F /* LoadingTableViewCell.swift */,
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */, D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */, D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */, D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
@ -1306,6 +1303,7 @@
D626494023C122C800612E6E /* Asset Picker */, D626494023C122C800612E6E /* Asset Picker */,
D6C7D27B22B6EBE200071952 /* Attachments */, D6C7D27B22B6EBE200071952 /* Attachments */,
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */, D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */,
D61959D0241E842400A37B8E /* Draft Cell */,
D611C2CC232DC5FC00C86A49 /* Hashtag Cell */, D611C2CC232DC5FC00C86A49 /* Hashtag Cell */,
D61AC1DA232EA43100C54D2D /* Instance Cell */, D61AC1DA232EA43100C54D2D /* Instance Cell */,
D641C78C213DD937004B4513 /* Notifications */, D641C78C213DD937004B4513 /* Notifications */,
@ -1361,7 +1359,6 @@
children = ( children = (
D63CC703290EC472000E19DE /* Dist.xcconfig */, D63CC703290EC472000E19DE /* Dist.xcconfig */,
D674A50727F910F300BA03AC /* Pachyderm */, D674A50727F910F300BA03AC /* Pachyderm */,
D6BEA243291A0C83002F4D01 /* Duckable */,
D6D4DDCE212518A000E1C4BB /* Tusker */, D6D4DDCE212518A000E1C4BB /* Tusker */,
D6D4DDE3212518A200E1C4BB /* TuskerTests */, D6D4DDE3212518A200E1C4BB /* TuskerTests */,
D6D4DDEE212518A200E1C4BB /* TuskerUITests */, D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
@ -1489,14 +1486,6 @@
path = "Crash Reporter"; path = "Crash Reporter";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D6F6A555291F4F0C00F496A8 /* Mute */ = {
isa = PBXGroup;
children = (
D6F6A556291F4F1600F496A8 /* MuteAccountView.swift */,
);
path = Mute;
sourceTree = "<group>";
};
D6F953F121251A2F00CF0F2B /* API */ = { D6F953F121251A2F00CF0F2B /* API */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -1505,9 +1494,6 @@
D6E9CDA7281A427800BBC98E /* PostService.swift */, D6E9CDA7281A427800BBC98E /* PostService.swift */,
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */, D61ABEFD28F1C92600B29151 /* FavoriteService.swift */,
D621733228F1D5ED004C7DB1 /* ReblogService.swift */, D621733228F1D5ED004C7DB1 /* ReblogService.swift */,
D6F6A54F291F058600F496A8 /* CreateListService.swift */,
D6F6A551291F098700F496A8 /* RenameListService.swift */,
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */,
); );
path = API; path = API;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1539,7 +1525,6 @@
D674A50827F9128D00BA03AC /* Pachyderm */, D674A50827F9128D00BA03AC /* Pachyderm */,
D6552366289870790048A653 /* ScreenCorners */, D6552366289870790048A653 /* ScreenCorners */,
D63CC701290EC0B8000E19DE /* Sentry */, D63CC701290EC0B8000E19DE /* Sentry */,
D6BEA244291A0EDE002F4D01 /* Duckable */,
); );
productName = Tusker; productName = Tusker;
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */; productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
@ -1674,6 +1659,7 @@
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */, D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */,
D626493923C0FD0000612E6E /* AllPhotosTableViewCell.xib in Resources */, D626493923C0FD0000612E6E /* AllPhotosTableViewCell.xib in Resources */,
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */, D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */,
D627FF7D217E958900CC0648 /* DraftTableViewCell.xib in Resources */,
D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */, D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */,
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */, D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */,
D6C82B5725C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib in Resources */, D6C82B5725C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib in Resources */,
@ -1685,6 +1671,7 @@
D663625D2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib in Resources */, D663625D2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib in Resources */,
D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */, D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */,
D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */, D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */,
D627FF79217E950100CC0648 /* DraftsTableViewController.xib in Resources */,
D626493D23C1000300612E6E /* AlbumTableViewCell.xib in Resources */, D626493D23C1000300612E6E /* AlbumTableViewCell.xib in Resources */,
D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */, D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */,
D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */, D64BC19023C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib in Resources */,
@ -1777,7 +1764,6 @@
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */, D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */,
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */, D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */, 0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
D6F6A54C291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift in Sources */,
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */, D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */,
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */, D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */, D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
@ -1818,7 +1804,6 @@
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */, D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */, D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */,
D627943723A552C200D38C68 /* BookmarkStatusActivity.swift in Sources */, D627943723A552C200D38C68 /* BookmarkStatusActivity.swift in Sources */,
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */, D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */,
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */, D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */, D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
@ -1865,10 +1850,9 @@
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */, D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */, D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */, 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */, D627FF7B217E951500CC0648 /* DraftsTableViewController.swift in Sources */,
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */, D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */,
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */, D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */, D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */, D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */,
D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */, D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */,
@ -1879,6 +1863,7 @@
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */, D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */, D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */,
D61DC84628F498F200B82C6E /* Logging.swift in Sources */, D61DC84628F498F200B82C6E /* Logging.swift in Sources */,
D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */,
D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */, D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */,
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */, D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */,
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */, D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */,
@ -1888,6 +1873,7 @@
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */, D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */, D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */, D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */,
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */, D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */,
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */, D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */, D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
@ -1902,7 +1888,6 @@
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */, D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */, D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */, D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */,
D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */, D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */,
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */, D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */, D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */,
@ -1936,8 +1921,6 @@
D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */, D681E4D3246E2AFF0053414F /* MuteConversationActivity.swift in Sources */,
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */, D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */, D64BC18623C1253A000D0238 /* AssetPreviewViewController.swift in Sources */,
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */,
D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */,
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */, D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */, D663626221360B1900C9CBA2 /* Preferences.swift in Sources */,
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */, D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
@ -1995,7 +1978,6 @@
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */, D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */, D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */, D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */,
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */, 04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */, D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */,
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */, D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */,
@ -2004,16 +1986,13 @@
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */, D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */,
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */, D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */, D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */,
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */, 04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */, D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */, D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */, D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */,
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */, D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */, D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */, D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
D6F6A54E291EF7E100F496A8 /* EditListSearchFollowingViewController.swift in Sources */,
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */, D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */, D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */, D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */,
@ -2187,7 +2166,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 45; CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2255,7 +2234,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 45; CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2405,7 +2384,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 45; CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2434,7 +2413,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 45; CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2544,7 +2523,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 45; CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2571,7 +2550,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 45; CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2710,10 +2689,6 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = Pachyderm; productName = Pachyderm;
}; };
D6BEA244291A0EDE002F4D01 /* Duckable */ = {
isa = XCSwiftPackageProductDependency;
productName = Duckable;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
/* Begin XCVersionGroup section */ /* Begin XCVersionGroup section */

View File

@ -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")
}

View File

@ -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)
}
}
}

View File

@ -10,12 +10,11 @@ import Foundation
import Pachyderm import Pachyderm
struct InstanceFeatures { 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 instanceType = InstanceType.mastodon
private(set) var version: Version? private(set) var version: Version?
private(set) var pleromaVersion: Version? private(set) var pleromaVersion: Version?
private(set) var hometownVersion: Version?
private(set) var maxStatusChars = 500 private(set) var maxStatusChars = 500
var localOnlyPosts: Bool { var localOnlyPosts: Bool {
@ -31,7 +30,7 @@ struct InstanceFeatures {
} }
var boostToOriginalAudience: Bool { var boostToOriginalAudience: Bool {
instanceType == .pleroma || instanceType.isMastodon instanceType == .pleroma || instanceType == .mastodon
} }
var profilePinnedStatuses: Bool { var profilePinnedStatuses: Bool {
@ -39,16 +38,16 @@ struct InstanceFeatures {
} }
var trends: Bool { var trends: Bool {
instanceType.isMastodon instanceType == .mastodon
} }
var trendingStatusesAndLinks: Bool { var trendingStatusesAndLinks: Bool {
instanceType.isMastodon && hasVersion(3, 5, 0) instanceType == .mastodon && hasVersion(3, 5, 0)
} }
var reblogVisibility: Bool { var reblogVisibility: Bool {
(instanceType.isMastodon && hasVersion(2, 8, 0)) (instanceType == .mastodon && hasVersion(2, 8, 0))
|| (instanceType == .pleroma && hasPleromaVersion(2, 0, 0)) || (instanceType == .pleroma && hasVersion(2, 0, 0))
} }
var probablySupportsMarkdown: Bool { var probablySupportsMarkdown: Bool {
@ -56,31 +55,24 @@ struct InstanceFeatures {
} }
mutating func update(instance: Instance, nodeInfo: NodeInfo?) { mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
var version: Version?
let ver = instance.version.lowercased() let ver = instance.version.lowercased()
if ver.contains("glitch") { if ver.contains("glitch") {
instanceType = .glitch instanceType = .glitch
} else if nodeInfo?.software.name == "hometown" { } else if nodeInfo?.software.name == "hometown" {
instanceType = .hometown instanceType = .hometown
// like "1.0.6+3.5.2"
let parts = ver.split(separator: "+")
if parts.count == 2 {
version = Version(string: String(parts[1]))
hometownVersion = Version(string: String(parts[0]))
}
} else if ver.contains("pleroma") { } else if ver.contains("pleroma") {
instanceType = .pleroma instanceType = .pleroma
if let match = InstanceFeatures.pleromaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) {
pleromaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 1)))
}
} else if ver.contains("pixelfed") { } else if ver.contains("pixelfed") {
instanceType = .pixelfed instanceType = .pixelfed
} else { } else {
instanceType = .mastodon instanceType = .mastodon
} }
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 maxStatusChars = instance.maxStatusCharacters ?? 500
} }
@ -92,14 +84,6 @@ struct InstanceFeatures {
return false return false
} }
} }
func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
if let pleromaVersion {
return pleromaVersion >= Version(major, minor, patch)
} else {
return false
}
}
} }
extension InstanceFeatures { extension InstanceFeatures {

View File

@ -50,7 +50,7 @@ class ReblogService {
} }
} else { } else {
image = nil image = nil
reblogVisibilityActions = nil reblogVisibilityActions = []
} }
let preview = ConfirmReblogStatusPreviewView(status: status) let preview = ConfirmReblogStatusPreviewView(status: status)

View File

@ -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")
}

View File

@ -81,7 +81,10 @@ class ImageCache {
guard !ImageCache.disableCaching else { return } guard !ImageCache.disableCaching else { return }
if !((try? cache.has(url.absoluteString)) ?? false) { if !((try? cache.has(url.absoluteString)) ?? false) {
let task = dataTask(url: url, 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() task.resume()
} }
} }
@ -92,9 +95,7 @@ class ImageCache {
let data else { let data else {
return return
} }
let image = UIImage(data: data) completion?(data, UIImage(data: data))
try? self.cache.set(url.absoluteString, data: data, image: image)
completion?(data, image)
} }
} }

View File

@ -221,11 +221,10 @@ class MastodonCachePersistentStore: NSPersistentContainer {
} }
} }
func addAll(accounts: [Account], in context: NSManagedObjectContext? = nil, completion: (() -> Void)? = nil) { func addAll(accounts: [Account], completion: (() -> Void)? = nil) {
let context = context ?? backgroundContext backgroundContext.perform {
context.perform { accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
accounts.forEach { self.upsert(account: $0, in: context) } self.save(context: self.backgroundContext)
self.save(context: context)
completion?() completion?()
accounts.forEach { self.accountSubject.send($0.id) } accounts.forEach { self.accountSubject.send($0.id) }
} }

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="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"> <entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/> <attribute name="acct" attributeType="String"/>
<attribute name="avatar" optional="YES" attributeType="URI"/> <attribute name="avatar" optional="YES" attributeType="URI"/>
@ -21,7 +21,6 @@
<attribute name="username" attributeType="String"/> <attribute name="username" attributeType="String"/>
<relationship name="movedTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/> <relationship name="movedTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
<relationship name="relationship" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Relationship" inverseName="account" inverseEntity="Relationship"/> <relationship name="relationship" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Relationship" inverseName="account" inverseEntity="Relationship"/>
<relationship name="statuses" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Status" inverseName="account" inverseEntity="Status"/>
<uniquenessConstraints> <uniquenessConstraints>
<uniquenessConstraint> <uniquenessConstraint>
<constraint value="id"/> <constraint value="id"/>
@ -85,7 +84,7 @@
<attribute name="uri" attributeType="String"/> <attribute name="uri" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="URI"/> <attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="visibilityString" attributeType="String"/> <attribute name="visibilityString" attributeType="String"/>
<relationship name="account" 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"/> <relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
<uniquenessConstraints> <uniquenessConstraints>
<uniquenessConstraint> <uniquenessConstraint>
@ -93,4 +92,11 @@
</uniquenessConstraint> </uniquenessConstraint>
</uniquenessConstraints> </uniquenessConstraints>
</entity> </entity>
<elements>
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="343"/>
<element name="Relationship" positionX="63" positionY="135" width="128" height="194"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="449"/>
<element name="SavedInstance" positionX="63" positionY="144" width="128" height="44"/>
<element name="SavedHashtag" positionX="72" positionY="153" width="128" height="59"/>
</elements>
</model> </model>

View File

@ -56,7 +56,6 @@ private let imageType = UTType.image.identifier
private let mp4Type = UTType.mpeg4Movie.identifier private let mp4Type = UTType.mpeg4Movie.identifier
private let quickTimeType = UTType.quickTimeMovie.identifier private let quickTimeType = UTType.quickTimeMovie.identifier
private let dataType = UTType.data.identifier private let dataType = UTType.data.identifier
private let gifType = UTType.gif.identifier
extension CompositionAttachment: NSItemProviderWriting { extension CompositionAttachment: NSItemProviderWriting {
static var writableTypeIdentifiersForItemProvider: [String] { static var writableTypeIdentifiersForItemProvider: [String] {
@ -96,22 +95,20 @@ extension CompositionAttachment: NSItemProviderReading {
[typeIdentifier] + UIImage.readableTypeIdentifiersForItemProvider + [mp4Type, quickTimeType] + NSURL.readableTypeIdentifiersForItemProvider [typeIdentifier] + UIImage.readableTypeIdentifiersForItemProvider + [mp4Type, quickTimeType] + NSURL.readableTypeIdentifiersForItemProvider
} }
static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> CompositionAttachment { static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> Self {
if typeIdentifier == CompositionAttachment.typeIdentifier { if typeIdentifier == CompositionAttachment.typeIdentifier {
return try PropertyListDecoder().decode(CompositionAttachment.self, from: data) return try PropertyListDecoder().decode(Self.self, from: data)
} else if typeIdentifier == gifType {
return CompositionAttachment(data: .gif(data))
} else if UIImage.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let image = try? UIImage.object(withItemProviderData: data, typeIdentifier: typeIdentifier) { } else if UIImage.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let image = try? UIImage.object(withItemProviderData: data, typeIdentifier: typeIdentifier) {
return CompositionAttachment(data: .image(image)) return CompositionAttachment(data: .image(image)) as! Self
} else if let type = UTType(typeIdentifier), type == .mpeg4Movie || type == .quickTimeMovie { } else if let type = UTType(typeIdentifier), type == .mpeg4Movie || type == .quickTimeMovie {
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let temporaryFileName = ProcessInfo().globallyUniqueString let temporaryFileName = ProcessInfo().globallyUniqueString
let fileExt = type.preferredFilenameExtension! let fileExt = type.preferredFilenameExtension!
let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName).appendingPathExtension(fileExt) let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName).appendingPathExtension(fileExt)
try data.write(to: temporaryFileURL) try data.write(to: temporaryFileURL)
return CompositionAttachment(data: .video(temporaryFileURL)) return CompositionAttachment(data: .video(temporaryFileURL)) as! Self
} else if NSURL.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let url = try? NSURL.object(withItemProviderData: data, typeIdentifier: typeIdentifier) as URL { } else if NSURL.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let url = try? NSURL.object(withItemProviderData: data, typeIdentifier: typeIdentifier) as URL {
return CompositionAttachment(data: .video(url)) return CompositionAttachment(data: .video(url)) as! Self
} else { } else {
throw ItemProviderError.incompatibleTypeIdentifier throw ItemProviderError.incompatibleTypeIdentifier
} }

View File

@ -16,7 +16,6 @@ enum CompositionAttachmentData {
case image(UIImage) case image(UIImage)
case video(URL) case video(URL)
case drawing(PKDrawing) case drawing(PKDrawing)
case gif(Data)
var type: AttachmentType { var type: AttachmentType {
switch self { switch self {
@ -28,8 +27,6 @@ enum CompositionAttachmentData {
return .video return .video
case .drawing(_): case .drawing(_):
return .image return .image
case .gif(_):
return .image
} }
} }
@ -72,22 +69,13 @@ enum CompositionAttachmentData {
} }
let utType: UTType let utType: UTType
let image = CIImage(data: data)! if dataUTI == "public.heic" {
let needsColorSpaceConversion = image.colorSpace?.name != CGColorSpace.sRGB
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG // 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) let image = CIImage(data: data)!
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
if needsColorSpaceConversion || dataUTI == "public.heic" {
let context = CIContext() let context = CIContext()
let sRGB = CGColorSpace(name: CGColorSpace.sRGB)! let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!
if dataUTI == "public.png" { data = context.jpegRepresentation(of: image, colorSpace: colorSpace, options: [:])!
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: sRGB)!
utType = .png
} else {
data = context.jpegRepresentation(of: image, colorSpace: sRGB)!
utType = .jpeg utType = .jpeg
}
} else { } else {
utType = UTType(dataUTI)! utType = UTType(dataUTI)!
} }
@ -122,8 +110,6 @@ enum CompositionAttachmentData {
case let .drawing(drawing): case let .drawing(drawing):
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1) let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
completion(.success((image.pngData()!, .png))) completion(.success((image.pngData()!, .png)))
case let .gif(data):
completion(.success((data, .gif)))
} }
} }
@ -196,8 +182,6 @@ extension CompositionAttachmentData: Codable {
try container.encode("drawing", forKey: .type) try container.encode("drawing", forKey: .type)
let drawingData = drawing.dataRepresentation() let drawingData = drawing.dataRepresentation()
try container.encode(drawingData, forKey: .drawing) try container.encode(drawingData, forKey: .drawing)
case .gif(_):
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: [], debugDescription: "gif CompositionAttachments cannot be encoded"))
} }
} }
@ -221,7 +205,7 @@ extension CompositionAttachmentData: Codable {
let drawing = try PKDrawing(data: drawingData) let drawing = try PKDrawing(data: drawingData)
self = .drawing(drawing) self = .drawing(drawing)
default: default:
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of image, asset, or drawing") throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of 'image' or 'asset'")
} }
} }

View File

@ -107,8 +107,6 @@ extension Draft: Equatable {
} }
} }
extension Draft: Identifiable {}
extension Draft { extension Draft {
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
class DraftsManager: Codable, ObservableObject { class DraftsManager: Codable {
private(set) static var shared: DraftsManager = load() private(set) static var shared: DraftsManager = load()
@ -48,12 +48,7 @@ class DraftsManager: Codable, ObservableObject {
} }
} }
func encode(to encoder: Encoder) throws { private var drafts: [UUID: Draft] = [:]
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(drafts, forKey: .drafts)
}
@Published private var drafts: [UUID: Draft] = [:]
var sorted: [Draft] { var sorted: [Draft] {
return drafts.values.sorted(by: { $0.lastModified > $1.lastModified }) return drafts.values.sorted(by: { $0.lastModified > $1.lastModified })
} }

View File

@ -9,7 +9,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
enum StatusFormat: Int, CaseIterable { enum StatusFormat: CaseIterable {
case bold, italics, strikethrough, code case bold, italics, strikethrough, code
var insertionResult: FormatInsertionResult? { var insertionResult: FormatInsertionResult? {
@ -23,17 +23,19 @@ enum StatusFormat: Int, CaseIterable {
} }
} }
var imageName: String? { var image: UIImage? {
let name: String
switch self { switch self {
case .italics: case .italics:
return "italic" name = "italic"
case .bold: case .bold:
return "bold" name = "bold"
case .strikethrough: case .strikethrough:
return "strikethrough" name = "strikethrough"
default: default:
return nil return nil
} }
return UIImage(systemName: name)
} }
var title: (String, [NSAttributedString.Key: Any])? { var title: (String, [NSAttributedString.Key: Any])? {

View File

@ -10,7 +10,6 @@ import UIKit
import Pachyderm import Pachyderm
import MessageUI import MessageUI
import CoreData import CoreData
import Duckable
class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate { class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
@ -126,19 +125,12 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest() let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest()
statusReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate) statusReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate)
let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq) let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq)
deleteStatusReq.resultType = .resultTypeCount _ = try? context.execute(deleteStatusReq)
if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult {
Logging.general.info("Pruned \(res.result as! Int) statuses")
}
let accountReq: NSFetchRequest<NSFetchRequestResult> = AccountMO.fetchRequest() let accountReq: NSFetchRequest<NSFetchRequestResult> = AccountMO.fetchRequest()
accountReq.predicate = NSPredicate(format: "((lastFetchedAt = nil) OR (lastFetchedAt < %@)) AND (statuses.@count = 0)", minDate as NSDate) accountReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate)
let deleteAccountReq = NSBatchDeleteRequest(fetchRequest: accountReq) let deleteAccountReq = NSBatchDeleteRequest(fetchRequest: accountReq)
deleteAccountReq.resultType = .resultTypeCount _ = try? context.execute(deleteAccountReq)
if let res = try? context.execute(deleteAccountReq) as? NSBatchDeleteResult {
Logging.general.info("Pruned \(res.result as! Int) accounts")
}
try? context.save() try? context.save()
} }
@ -206,14 +198,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
mastodonController.getOwnAccount() mastodonController.getOwnAccount()
mastodonController.getOwnInstance() mastodonController.getOwnInstance()
let split = MainSplitViewController(mastodonController: mastodonController) return MainSplitViewController(mastodonController: mastodonController)
if UIDevice.current.userInterfaceIdiom == .phone,
#available(iOS 16.0, *) {
// TODO: maybe the duckable container should be outside the account switching container
return DuckableContainerViewController(child: split)
} else {
return split
}
} }
func createOnboardingUI() -> UIViewController { func createOnboardingUI() -> UIViewController {

View File

@ -13,14 +13,18 @@ import AVKit
class AssetPreviewViewController: UIViewController { class AssetPreviewViewController: UIViewController {
let asset: PHAsset let attachment: CompositionAttachmentData
init(asset: PHAsset) { init(attachment: CompositionAttachmentData) {
self.asset = asset self.attachment = attachment
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
convenience init(asset: PHAsset) {
self.init(attachment: .asset(asset))
}
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
@ -30,6 +34,12 @@ class AssetPreviewViewController: UIViewController {
view.backgroundColor = .black view.backgroundColor = .black
switch attachment {
case let .image(image):
showImage(image)
case let .video(url):
showVideo(asset: AVURLAsset(url: url))
case let .asset(asset):
switch asset.mediaType { switch asset.mediaType {
case .image: case .image:
if asset.mediaSubtypes.contains(.photoLive) { if asset.mediaSubtypes.contains(.photoLive) {
@ -42,6 +52,10 @@ class AssetPreviewViewController: UIViewController {
default: default:
fatalError("asset mediaType must be image or video") fatalError("asset mediaType must be image or video")
} }
case let .drawing(drawing):
let image = drawing.imageInLightMode(from: drawing.bounds)
showImage(image)
}
} }
func showImage(_ image: UIImage) { func showImage(_ image: UIImage) {

View File

@ -10,30 +10,14 @@ import UIKit
import AVKit import AVKit
import Pachyderm import Pachyderm
class GalleryPlayerViewController: UIViewController { class GalleryPlayerViewController: AVPlayerViewController {
let playerVC = AVPlayerViewController()
var attachment: Attachment! var attachment: Attachment!
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .black allowsPictureInPicturePlayback = true
playerVC.allowsPictureInPicturePlayback = true
playerVC.view.translatesAutoresizingMaskIntoConstraints = false
addChild(playerVC)
playerVC.didMove(toParent: self)
view.addSubview(playerVC.view)
NSLayoutConstraint.activate([
playerVC.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
playerVC.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
playerVC.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
playerVC.view.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
])
} }
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {

View File

@ -93,8 +93,8 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
return vc return vc
case .video, .audio: case .video, .audio:
let vc = GalleryPlayerViewController() let vc = GalleryPlayerViewController()
vc.playerVC.player = AVPlayer(url: attachment.url) vc.player = AVPlayer(url: attachment.url)
vc.playerVC.delegate = avPlayerViewControllerDelegate vc.delegate = avPlayerViewControllerDelegate
vc.attachment = attachment vc.attachment = attachment
return vc return vc
case .gifv: case .gifv:

View File

@ -13,7 +13,6 @@ struct ComposeAttachmentImage: View {
let attachment: CompositionAttachment let attachment: CompositionAttachment
let fullSize: Bool let fullSize: Bool
@State private var gifData: Data? = nil
@State private var image: UIImage? = nil @State private var image: UIImage? = nil
@State private var imageContentMode: ContentMode = .fill @State private var imageContentMode: ContentMode = .fill
@State private var imageBackgroundColor: Color = .black @State private var imageBackgroundColor: Color = .black
@ -21,9 +20,7 @@ struct ComposeAttachmentImage: View {
@Environment(\.colorScheme) private var colorScheme: ColorScheme @Environment(\.colorScheme) private var colorScheme: ColorScheme
var body: some View { var body: some View {
if let gifData { if let image = image {
GIFViewWrapper(gifData: gifData)
} else if let image {
Image(uiImage: image) Image(uiImage: image)
.resizable() .resizable()
.aspectRatio(contentMode: imageContentMode) .aspectRatio(contentMode: imageContentMode)
@ -57,25 +54,11 @@ struct ComposeAttachmentImage: View {
// currently only used as thumbnail in ComposeAttachmentRow // currently only used as thumbnail in ComposeAttachmentRow
size = CGSize(width: 80, height: 80) size = CGSize(width: 80, height: 80)
} }
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 PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
DispatchQueue.main.async { DispatchQueue.main.async {
self.image = image self.image = image
} }
} }
}
case let .video(url): case let .video(url):
let asset = AVURLAsset(url: url) let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset) let imageGenerator = AVAssetImageGenerator(asset: asset)
@ -86,35 +69,10 @@ struct ComposeAttachmentImage: View {
image = drawing.imageInLightMode(from: drawing.bounds) image = drawing.imageInLightMode(from: drawing.bounds)
imageContentMode = .fit imageContentMode = .fit
imageBackgroundColor = .white imageBackgroundColor = .white
case let .gif(data):
self.gifData = data
} }
} }
} }
private struct GIFViewWrapper: UIViewRepresentable {
typealias UIViewType = GIFImageView
@State private var controller: GIFController
init(gifData: Data) {
self._controller = State(wrappedValue: GIFController(gifData: gifData))
}
func makeUIView(context: Context) -> GIFImageView {
let view = GIFImageView()
controller.attach(to: view)
controller.startAnimating()
view.contentMode = .scaleAspectFit
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
return view
}
func updateUIView(_ uiView: GIFImageView, context: Context) {
}
}
struct ComposeAttachmentImage_Previews: PreviewProvider { struct ComposeAttachmentImage_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ComposeAttachmentImage(attachment: CompositionAttachment(data: .image(UIImage())), fullSize: false) ComposeAttachmentImage(attachment: CompositionAttachment(data: .image(UIImage())), fullSize: false)

View File

@ -14,6 +14,7 @@ import Vision
struct ComposeAttachmentRow: View { struct ComposeAttachmentRow: View {
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
@ObservedObject var attachment: CompositionAttachment @ObservedObject var attachment: CompositionAttachment
let heightChanged: (CGFloat) -> Void
@EnvironmentObject var uiState: ComposeUIState @EnvironmentObject var uiState: ComposeUIState
@State private var mode: Mode = .allowEntry @State private var mode: Mode = .allowEntry
@ -46,6 +47,7 @@ struct ComposeAttachmentRow: View {
switch mode { switch mode {
case .allowEntry: case .allowEntry:
ComposeTextView(text: $attachment.attachmentDescription, placeholder: Text("Describe for the visually impaired…"), minHeight: 80) ComposeTextView(text: $attachment.attachmentDescription, placeholder: Text("Describe for the visually impaired…"), minHeight: 80)
.heightDidChange(self.heightChanged)
.backgroundColor(.clear) .backgroundColor(.clear)
case .recognizingText: case .recognizingText:

View File

@ -18,17 +18,23 @@ struct ComposeAttachmentsList: View {
@EnvironmentObject var uiState: ComposeUIState @EnvironmentObject var uiState: ComposeUIState
@State var isShowingAssetPickerPopover = false @State var isShowingAssetPickerPopover = false
@State var isShowingCreateDrawing = false @State var isShowingCreateDrawing = false
@State var rowHeights = [UUID: CGFloat]()
@Environment(\.colorScheme) var colorScheme: ColorScheme @Environment(\.colorScheme) var colorScheme: ColorScheme
@Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass? @Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass?
var body: some View { var body: some View {
Group { List {
ForEach(draft.attachments) { (attachment) in ForEach(draft.attachments) { (attachment) in
ComposeAttachmentRow( ComposeAttachmentRow(
draft: draft, draft: draft,
attachment: attachment attachment: attachment
) ) { (newHeight) in
// in case height changed callback is called after atachment is removed but before view hierarchy is updated
if draft.attachments.contains(where: { $0.id == attachment.id }) {
rowHeights[attachment.id] = newHeight
}
}
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
.onDrag { NSItemProvider(object: attachment) } .onDrag { NSItemProvider(object: attachment) }
} }
@ -63,7 +69,12 @@ struct ComposeAttachmentsList: View {
.frame(height: cellHeight / 2) .frame(height: cellHeight / 2)
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
} }
.listStyle(PlainListStyle())
// todo: scrollDisabled doesn't remove the need for manually calculating the frame height
.frame(height: totalListHeight)
.scrollDisabledIfAvailable(totalHeight: totalListHeight)
.onAppear(perform: self.didAppear) .onAppear(perform: self.didAppear)
.onReceive(draft.$attachments, perform: self.attachmentsChanged)
} }
private var addButtonImageName: String { private var addButtonImageName: String {
@ -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() { private func didAppear() {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
// these appearance proxy hacks are no longer necessary // these appearance proxy hacks are no longer necessary
@ -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 { private func assetPickerPopover() -> some View {
ComposeAssetPicker(draft: draft, delegate: uiState.delegate?.assetPickerDelegate) ComposeAssetPicker(draft: draft, delegate: uiState.delegate?.assetPickerDelegate)
.onDisappear { .onDisappear {
@ -185,6 +214,16 @@ fileprivate extension View {
self self
} }
} }
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDisabledIfAvailable(totalHeight: CGFloat) -> some View {
if #available(iOS 16.0, *) {
self.scrollDisabled(true)
} else {
self.frame(height: totalHeight)
}
}
} }
@available(iOS 16.0, *) @available(iOS 16.0, *)

View File

@ -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()
// }
//}

View File

@ -13,19 +13,15 @@ struct ComposeEmojiTextField: UIViewRepresentable {
@EnvironmentObject private var uiState: ComposeUIState @EnvironmentObject private var uiState: ComposeUIState
@Binding var text: String @Binding private var text: String
let placeholder: String private let placeholder: String
let becomeFirstResponder: Binding<Bool>? private var didChange: ((String) -> Void)?
let focusNextView: Binding<Bool>? private var didEndEditing: (() -> Void)?
private var didChange: ((String) -> Void)? = nil
private var didEndEditing: (() -> Void)? = nil
private var backgroundColor: UIColor? = nil private var backgroundColor: UIColor? = nil
init(text: Binding<String>, placeholder: String, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) { init(text: Binding<String>, placeholder: String) {
self._text = text self._text = text
self.placeholder = placeholder self.placeholder = placeholder
self.becomeFirstResponder = becomeFirstResponder
self.focusNextView = focusNextView
self.didChange = nil self.didChange = nil
self.didEndEditing = nil self.didEndEditing = nil
} }
@ -56,7 +52,6 @@ struct ComposeEmojiTextField: UIViewRepresentable {
view.delegate = context.coordinator view.delegate = context.coordinator
view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged) view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged)
view.addTarget(context.coordinator, action: #selector(Coordinator.returnKeyPressed), for: .primaryActionTriggered)
// otherwise when the text gets too wide it starts expanding the ComposeView // otherwise when the text gets too wide it starts expanding the ComposeView
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
@ -76,14 +71,6 @@ struct ComposeEmojiTextField: UIViewRepresentable {
} }
context.coordinator.didChange = didChange context.coordinator.didChange = didChange
context.coordinator.didEndEditing = didEndEditing context.coordinator.didEndEditing = didEndEditing
context.coordinator.focusNextView = focusNextView
if becomeFirstResponder?.wrappedValue == true {
DispatchQueue.main.async {
uiView.becomeFirstResponder()
becomeFirstResponder?.wrappedValue = false
}
}
} }
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
@ -97,7 +84,6 @@ struct ComposeEmojiTextField: UIViewRepresentable {
unowned var uiState: ComposeUIState! unowned var uiState: ComposeUIState!
var didChange: ((String) -> Void)? var didChange: ((String) -> Void)?
var didEndEditing: (() -> Void)? var didEndEditing: (() -> Void)?
var focusNextView: Binding<Bool>?
var skipSettingTextOnNextUpdate = false var skipSettingTextOnNextUpdate = false
@ -110,17 +96,12 @@ struct ComposeEmojiTextField: UIViewRepresentable {
didChange?(text.wrappedValue) didChange?(text.wrappedValue)
} }
@objc func returnKeyPressed() {
focusNextView?.wrappedValue = true
}
func textFieldDidBeginEditing(_ textField: UITextField) { func textFieldDidBeginEditing(_ textField: UITextField) {
uiState.currentInput = self uiState.currentInput = self
updateAutocompleteState(textField: textField) updateAutocompleteState(textField: textField)
} }
func textFieldDidEndEditing(_ textField: UITextField) { func textFieldDidEndEditing(_ textField: UITextField) {
uiState.currentInput = nil
updateAutocompleteState(textField: textField) updateAutocompleteState(textField: textField)
didEndEditing?() didEndEditing?()
} }

View File

@ -10,16 +10,14 @@ import SwiftUI
import Combine import Combine
import Pachyderm import Pachyderm
import PencilKit import PencilKit
import Duckable
protocol ComposeHostingControllerDelegate: AnyObject { protocol ComposeHostingControllerDelegate: AnyObject {
func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool
} }
class ComposeHostingController: UIHostingController<ComposeView>, DuckableViewController { class ComposeHostingController: UIHostingController<ComposeContainerView> {
weak var delegate: ComposeHostingControllerDelegate? weak var delegate: ComposeHostingControllerDelegate?
weak var duckableDelegate: DuckableViewControllerDelegate?
let mastodonController: MastodonController let mastodonController: MastodonController
@ -29,6 +27,13 @@ class ComposeHostingController: UIHostingController<ComposeView>, DuckableViewCo
private var cancellables = [AnyCancellable]() private var cancellables = [AnyCancellable]()
private var toolbarHeight: CGFloat = 44
private var mainToolbar: UIToolbar!
private var inputAccessoryToolbar: UIToolbar!
override var inputAccessoryView: UIView? { inputAccessoryToolbar }
init(draft: Draft? = nil, mastodonController: MastodonController) { init(draft: Draft? = nil, mastodonController: MastodonController) {
self.mastodonController = mastodonController self.mastodonController = mastodonController
let realDraft = draft ?? Draft(accountID: mastodonController.accountInfo!.id) let realDraft = draft ?? Draft(accountID: mastodonController.accountInfo!.id)
@ -36,18 +41,49 @@ class ComposeHostingController: UIHostingController<ComposeView>, DuckableViewCo
self.uiState = ComposeUIState(draft: realDraft) 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, mastodonController: mastodonController,
uiState: uiState uiState: uiState
) )
super.init(rootView: compose) super.init(rootView: container)
self.uiState.delegate = self self.uiState.delegate = self
// main toolbar is shown at the bottom of the screen, the input accessory is attached to the keyboard while editing
// (except for MainComposeTextView which has its own accessory to add formatting buttons)
mainToolbar = UIToolbar()
mainToolbar.translatesAutoresizingMaskIntoConstraints = false
mainToolbar.isAccessibilityElement = true
setupToolbarItems(toolbar: mainToolbar, input: nil)
inputAccessoryToolbar = UIToolbar()
inputAccessoryToolbar.translatesAutoresizingMaskIntoConstraints = false
inputAccessoryToolbar.isAccessibilityElement = true
setupToolbarItems(toolbar: inputAccessoryToolbar, input: nil)
NotificationCenter.default.addObserver(self, selector: #selector(composeKeyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(composeKeyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(composeKeyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil)
// add the height of the toolbar itself to the bottom of the safe area so content inside SwiftUI ScrollView doesn't underflow it
updateAdditionalSafeAreaInsets()
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self) pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id) userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id)
self.uiState.$draft
.flatMap(\.$visibility)
.sink(receiveValue: self.visibilityChanged)
.store(in: &cancellables)
self.uiState.$draft
.flatMap(\.$localOnly)
.sink(receiveValue: self.localOnlyChanged)
.store(in: &cancellables)
self.uiState.$draft self.uiState.$draft
.flatMap(\.objectWillChange) .flatMap(\.objectWillChange)
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility)) .debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility))
@ -55,12 +91,32 @@ class ComposeHostingController: UIHostingController<ComposeView>, DuckableViewCo
DraftsManager.save() DraftsManager.save()
} }
.store(in: &cancellables) .store(in: &cancellables)
self.uiState.$currentInput
.sink { [unowned self] in
self.setupToolbarItems(toolbar: self.inputAccessoryToolbar, input: $0)
}
.store(in: &cancellables)
} }
required init?(coder aDecoder: NSCoder) { required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if let parent = parent {
parent.view.addSubview(mainToolbar)
NSLayoutConstraint.activate([
mainToolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
mainToolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
// use the top anchor of the toolbar so our additionalSafeAreaInsets (which has the bottom as the toolbar height) don't affect it
mainToolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
])
}
}
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
@ -70,6 +126,159 @@ class ComposeHostingController: UIHostingController<ComposeView>, DuckableViewCo
DraftsManager.save() DraftsManager.save()
} }
private func setupToolbarItems(toolbar: UIToolbar, input: ComposeInput?) {
var items: [UIBarButtonItem] = []
items.append(UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed)))
let visibilityItem = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil)
visibilityItem.tag = ViewTags.composeVisibilityBarButton
items.append(visibilityItem)
if mastodonController.instanceFeatures.localOnlyPosts {
let item = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil)
item.tag = ViewTags.composeLocalOnlyBarButton
items.append(item)
localOnlyChanged(draft.localOnly)
}
if input?.toolbarElements.contains(.emojiPicker) == true {
items.append(UIBarButtonItem(image: UIImage(systemName: "face.smiling"), style: .plain, target: self, action: #selector(emojiPickerButtonPressed)))
}
items.append(UIBarButtonItem(systemItem: .flexibleSpace))
if input?.toolbarElements.contains(.formattingButtons) == true,
Preferences.shared.statusContentType != .plain {
for (idx, format) in StatusFormat.allCases.enumerated() {
let item: UIBarButtonItem
if let image = format.image {
item = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(formatButtonPressed(_:)))
} else if let (str, attributes) = format.title {
item = UIBarButtonItem(title: str, style: .plain, target: self, action: #selector(formatButtonPressed(_:)))
item.setTitleTextAttributes(attributes, for: .normal)
item.setTitleTextAttributes(attributes, for: .highlighted)
} else {
fatalError("StatusFormat must have either image or title")
}
item.tag = StatusFormat.allCases.firstIndex(of: format)!
item.accessibilityLabel = format.accessibilityLabel
items.append(item)
if idx != StatusFormat.allCases.count - 1 {
let spacer = UIBarButtonItem(systemItem: .fixedSpace)
spacer.width = 8
items.append(spacer)
}
}
items.append(UIBarButtonItem(systemItem: .flexibleSpace))
}
items.append(UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPresed)))
toolbar.items = items
visibilityChanged(draft.visibility)
localOnlyChanged(draft.localOnly)
}
private func updateAdditionalSafeAreaInsets() {
additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: toolbarHeight, right: 0)
}
@objc private func composeKeyboardWillShow(_ notification: Foundation.Notification) {
keyboardWillShow(accessoryView: inputAccessoryToolbar, notification: notification)
}
func keyboardWillShow(accessoryView: UIView, notification: Foundation.Notification) {
mainToolbar.isHidden = true
accessoryView.alpha = 1
accessoryView.isHidden = false
}
@objc private func composeKeyboardWillHide(_ notification: Foundation.Notification) {
keyboardWillHide(accessoryView: inputAccessoryToolbar, notification: notification)
}
func keyboardWillHide(accessoryView: UIView, notification: Foundation.Notification) {
mainToolbar.isHidden = false
let userInfo = notification.userInfo!
let durationObj = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as! NSNumber
let duration = TimeInterval(durationObj.doubleValue)
let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as! NSNumber
let curve = UIView.AnimationCurve(rawValue: curveValue.intValue)!
let curveOption: UIView.AnimationOptions
switch curve {
case .easeInOut:
curveOption = .curveEaseInOut
case .easeIn:
curveOption = .curveEaseIn
case .easeOut:
curveOption = .curveEaseOut
case .linear:
curveOption = .curveLinear
@unknown default:
curveOption = .curveLinear
}
UIView.animate(withDuration: duration, delay: 0, options: curveOption) {
accessoryView.alpha = 0
} completion: { (finished) in
accessoryView.alpha = 1
}
}
@objc private func composeKeyboardDidHide(_ notification: Foundation.Notification) {
keyboardDidHide(accessoryView: inputAccessoryToolbar, notification: notification)
}
func keyboardDidHide(accessoryView: UIView, notification: Foundation.Notification) {
accessoryView.isHidden = true
}
private func visibilityChanged(_ newVisibility: Status.Visibility) {
for toolbar in [mainToolbar, inputAccessoryToolbar] {
guard let item = toolbar?.items?.first(where: { $0.tag == ViewTags.composeVisibilityBarButton }) else {
continue
}
item.image = UIImage(systemName: newVisibility.imageName)
item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName)
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
let state = visibility == newVisibility ? UIMenuElement.State.on : .off
return UIAction(title: visibility.displayName, subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName), state: state) { [unowned self] (_) in
self.draft.visibility = visibility
}
}
item.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements)
}
}
private func localOnlyChanged(_ localOnly: Bool) {
for toolbar in [mainToolbar, inputAccessoryToolbar] {
guard let item = toolbar?.items?.first(where: { $0.tag == ViewTags.composeLocalOnlyBarButton }) else {
continue
}
if localOnly {
item.image = UIImage(named: "link.broken")
item.accessibilityLabel = "Local-only"
} else {
item.image = UIImage(systemName: "link")
item.accessibilityLabel = "Federated"
}
let instanceSubtitle = "Only \(mastodonController.accountInfo!.instanceURL.host!)"
item.menu = UIMenu(children: [
UIAction(title: "Local-only", subtitle: instanceSubtitle, image: UIImage(named: "link.broken"), state: localOnly ? .on : .off) { [unowned self] (_) in
self.draft.localOnly = true
},
UIAction(title: "Federated", image: UIImage(systemName: "link"), state: localOnly ? .off : .on) { [unowned self] (_) in
self.draft.localOnly = false
},
])
}
}
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false } guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false }
if mastodonController.instanceFeatures.mastodonAttachmentRestrictions { if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
@ -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 // MARK: Interaction
@objc func cwButtonPressed() { @objc func cwButtonPressed() {
@ -124,7 +321,9 @@ class ComposeHostingController: UIHostingController<ComposeView>, DuckableViewCo
} }
@objc func draftsButtonPresed() { @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 let dismissed = delegate?.dismissCompose(mode: mode) ?? false
if !dismissed { if !dismissed {
self.dismiss(animated: true) self.dismiss(animated: true)
self.duckableDelegate?.duckableViewControllerWillDismiss(animated: true)
} }
} }
@ -164,16 +362,6 @@ extension ComposeHostingController: ComposeUIStateDelegate {
present(ComposeDrawingNavigationController(editing: drawing, delegate: self), animated: true) present(ComposeDrawingNavigationController(editing: drawing, delegate: self), animated: true)
} }
func selectDraft(_ draft: Draft) {
if self.draft.hasContent {
DraftsManager.save()
} else {
DraftsManager.shared.remove(self.draft)
}
uiState.draft = draft
uiState.isShowingDraftsList = false
}
} }
extension ComposeHostingController: AssetPickerViewControllerDelegate { extension ComposeHostingController: AssetPickerViewControllerDelegate {
@ -200,17 +388,47 @@ extension ComposeHostingController: AssetPickerViewControllerDelegate {
} }
} }
// superseded by duckable stuff extension ComposeHostingController: DraftsTableViewControllerDelegate {
@available(iOS, obsoleted: 16.0) 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 { extension ComposeHostingController: UIAdaptivePresentationControllerDelegate {
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
return Preferences.shared.automaticallySaveDrafts || !draft.hasContent return Preferences.shared.automaticallySaveDrafts || !draft.hasContent
} }
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: self, for: nil)
}
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
uiState.isShowingSaveDraftSheet = true uiState.isShowingSaveDraftSheet = true
} }

View File

@ -44,8 +44,6 @@ struct ComposePollView: View {
.imageScale(.small) .imageScale(.small)
.padding(4) .padding(4)
} }
.accessibilityLabel("Remove poll")
.buttonStyle(.plain)
.accentColor(buttonForegroundColor) .accentColor(buttonForegroundColor)
.background(Circle().foregroundColor(buttonBackgroundColor)) .background(Circle().foregroundColor(buttonBackgroundColor))
.hoverEffect() .hoverEffect()
@ -54,22 +52,31 @@ struct ComposePollView: View {
ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in
ComposePollOption(poll: poll, option: e.element, optionIndex: e.offset) ComposePollOption(poll: poll, option: e.element, optionIndex: e.offset)
} }
.transition(.slide)
Button(action: self.addOption) { Button(action: self.addOption) {
Label("Add Option", systemImage: "plus") Label("Add Option", systemImage: "plus")
} }
.buttonStyle(.borderless)
HStack { HStack {
MenuPicker(selection: $poll.multiple, options: [ // use .animation(nil) on pickers so frame doesn't have a size change animation when the text changes
.init(value: true, title: "Allow multiple"), // this is deprecated in iOS 15, but using .animation(nil, value: poll.multiple) does not work (it still animates)
.init(value: false, title: "Single choice"), // 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) .frame(maxWidth: .infinity)
MenuPicker(selection: $duration, options: Duration.allCases.map { Picker(selection: $duration, label: Text(verbatim: ComposePollView.formatter.string(from: duration.timeInterval)!)) {
.init(value: $0, title: ComposePollView.formatter.string(from: $0.timeInterval)!) ForEach(Duration.allCases, id: \.self) { (duration) in
}) Text(ComposePollView.formatter.string(from: duration.timeInterval)!).tag(duration)
}
}
.animation(nil)
.pickerStyle(MenuPickerStyle())
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
@ -103,8 +110,10 @@ struct ComposePollView: View {
} }
private func addOption() { private func addOption() {
withAnimation(.easeInOut(duration: 0.25)) {
poll.options.append(Draft.Poll.Option("")) poll.options.append(Draft.Poll.Option(""))
} }
}
} }
extension ComposePollView { extension ComposePollView {
@ -154,7 +163,6 @@ struct ComposePollOption: View {
Button(action: self.removeOption) { Button(action: self.removeOption) {
Image(systemName: "minus.circle.fill") Image(systemName: "minus.circle.fill")
} }
.buttonStyle(.plain)
.foregroundColor(poll.options.count == 1 ? .gray : .red) .foregroundColor(poll.options.count == 1 ? .gray : .red)
.disabled(poll.options.count == 1) .disabled(poll.options.count == 1)
.hoverEffect() .hoverEffect()
@ -167,8 +175,10 @@ struct ComposePollOption: View {
} }
private func removeOption() { private func removeOption() {
_ = withAnimation {
poll.options.remove(at: optionIndex) poll.options.remove(at: optionIndex)
} }
}
struct Checkbox: View { struct Checkbox: View {
private let radiusFraction: CGFloat private let radiusFraction: CGFloat

View File

@ -17,20 +17,18 @@ struct ComposeReplyContentView: UIViewRepresentable {
let heightChanged: (CGFloat) -> Void let heightChanged: (CGFloat) -> Void
func makeUIView(context: Context) -> UIViewType { func makeUIView(context: Context) -> ComposeReplyContentTextView {
let view = ComposeReplyContentTextView() let view = ComposeReplyContentTextView()
view.overrideMastodonController = mastodonController view.overrideMastodonController = mastodonController
view.setTextFrom(status: status) view.setTextFrom(status: status)
view.isUserInteractionEnabled = false view.isUserInteractionEnabled = false
// scroll needs to be enabled, otherwise the text view never reports a contentSize greater than 1 line
view.isScrollEnabled = true
view.backgroundColor = .clear view.backgroundColor = .clear
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return view return view
} }
func updateUIView(_ uiView: UIViewType, context: Context) { func updateUIView(_ uiView: ComposeReplyContentTextView, context: Context) {
uiView.heightChanged = heightChanged uiView.heightChanged = heightChanged
} }
} }

View File

@ -10,8 +10,7 @@ import SwiftUI
struct ComposeReplyView: View { struct ComposeReplyView: View {
let status: StatusMO let status: StatusMO
let rowTopInset: CGFloat let stackPadding: CGFloat
let globalFrameOutsideList: CGRect
@State private var displayNameHeight: CGFloat? @State private var displayNameHeight: CGFloat?
@State private var contentHeight: CGFloat? @State private var contentHeight: CGFloat?
@ -47,12 +46,8 @@ struct ComposeReplyView: View {
}) })
ComposeReplyContentView(status: status) { newHeight in 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) .frame(height: contentHeight ?? 0)
} }
} }
@ -60,12 +55,10 @@ struct ComposeReplyView: View {
} }
private func replyAvatarImage(geometry: GeometryProxy) -> some 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 scrollOffset = -geometry.frame(in: .named(ComposeView.coordinateSpaceOutsideOfScrollView)).minY
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 // add stackPadding so that the image is always at least stackPadding away from the top
var offset = scrollOffset + rowTopInset var offset = scrollOffset + stackPadding
// offset can never be less than 0 (i.e., above the top of the in-reply-to content) // offset can never be less than 0 (i.e., above the top of the in-reply-to content)
offset = max(offset, 0) offset = max(offset, 0)

View File

@ -69,8 +69,6 @@ struct WrappedTextView: UIViewRepresentable {
var textDidChange: ((UITextView) -> Void)? var textDidChange: ((UITextView) -> Void)?
var font = UIFont.systemFont(ofSize: 20) var font = UIFont.systemFont(ofSize: 20)
@Environment(\.isEnabled) private var isEnabled: Bool
func makeUIView(context: Context) -> UITextView { func makeUIView(context: Context) -> UITextView {
let textView = UITextView() let textView = UITextView()
textView.delegate = context.coordinator textView.delegate = context.coordinator
@ -84,8 +82,6 @@ struct WrappedTextView: UIViewRepresentable {
func updateUIView(_ uiView: UITextView, context: Context) { func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text uiView.text = text
uiView.isEditable = isEnabled
context.coordinator.textView = uiView
context.coordinator.text = $text context.coordinator.text = $text
context.coordinator.didChange = textDidChange context.coordinator.didChange = textDidChange
// wait until the next runloop iteration so that SwiftUI view updates have finished and // wait until the next runloop iteration so that SwiftUI view updates have finished and
@ -100,7 +96,6 @@ struct WrappedTextView: UIViewRepresentable {
} }
class Coordinator: NSObject, UITextViewDelegate, ComposeTextViewCaretScrolling { class Coordinator: NSObject, UITextViewDelegate, ComposeTextViewCaretScrolling {
weak var textView: UITextView?
var text: Binding<String> var text: Binding<String>
var didChange: ((UITextView) -> Void)? var didChange: ((UITextView) -> Void)?
var caretScrollPositionAnimator: UIViewPropertyAnimator? var caretScrollPositionAnimator: UIViewPropertyAnimator?
@ -108,16 +103,6 @@ struct WrappedTextView: UIViewRepresentable {
init(text: Binding<String>, didChange: ((UITextView) -> Void)?) { init(text: Binding<String>, didChange: ((UITextView) -> Void)?) {
self.text = text self.text = text
self.didChange = didChange self.didChange = didChange
super.init()
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil)
}
@objc private func keyboardDidShow() {
guard let textView,
textView.isFirstResponder else { return }
ensureCursorVisible(textView: textView)
} }
func textViewDidChange(_ textView: UITextView) { func textViewDidChange(_ textView: UITextView) {

View File

@ -37,7 +37,7 @@ extension ComposeTextViewCaretScrolling {
rectToMakeVisible.origin.y -= cursorRect.height rectToMakeVisible.origin.y -= cursorRect.height
rectToMakeVisible.size.height *= 3 rectToMakeVisible.size.height *= 3
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) { let animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false) scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
} }
self.caretScrollPositionAnimator = animator self.caretScrollPositionAnimator = animator

View File

@ -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: ""))
}
}

View File

@ -15,7 +15,10 @@ protocol ComposeUIStateDelegate: AnyObject {
// @available(iOS, obsoleted: 16.0) // @available(iOS, obsoleted: 16.0)
func presentAssetPickerSheet() func presentAssetPickerSheet()
func presentComposeDrawing() func presentComposeDrawing()
func selectDraft(_ draft: Draft)
func keyboardWillShow(accessoryView: UIView, notification: Notification)
func keyboardWillHide(accessoryView: UIView, notification: Notification)
func keyboardDidHide(accessoryView: UIView, notification: Notification)
} }
class ComposeUIState: ObservableObject { class ComposeUIState: ObservableObject {
@ -24,10 +27,8 @@ class ComposeUIState: ObservableObject {
@Published var draft: Draft @Published var draft: Draft
@Published var isShowingSaveDraftSheet = false @Published var isShowingSaveDraftSheet = false
@Published var isShowingDraftsList = false
@Published var attachmentsMissingDescriptions = Set<UUID>() @Published var attachmentsMissingDescriptions = Set<UUID>()
@Published var autocompleteState: AutocompleteState? = nil @Published var autocompleteState: AutocompleteState? = nil
@Published var isDucking = false
var composeDrawingMode: ComposeDrawingMode? var composeDrawingMode: ComposeDrawingMode?

View File

@ -42,13 +42,11 @@ import Combine
} }
struct ComposeView: View { struct ComposeView: View {
@ObservedObject var draft: Draft static let coordinateSpaceOutsideOfScrollView = "coordinateSpaceOutsideOfScrollView"
@ObservedObject var mastodonController: MastodonController
@ObservedObject var uiState: ComposeUIState
@State private var globalFrameOutsideList: CGRect = .zero @ObservedObject var draft: Draft
@State private var contentWarningBecomeFirstResponder = false @EnvironmentObject var mastodonController: MastodonController
@State private var mainComposeTextViewBecomeFirstResponder = false @EnvironmentObject var uiState: ComposeUIState
@OptionalStateObject private var poster: PostService? @OptionalStateObject private var poster: PostService?
@State private var isShowingPostErrorAlert = false @State private var isShowingPostErrorAlert = false
@ -60,67 +58,42 @@ struct ComposeView: View {
private let stackPadding: CGFloat = 8 private let stackPadding: CGFloat = 8
init(mastodonController: MastodonController, uiState: ComposeUIState) { init(draft: Draft) {
self.draft = uiState.draft self.draft = draft
self.mastodonController = mastodonController
self.uiState = uiState
} }
private var charactersRemaining: Int { var charactersRemaining: Int {
let limit = mastodonController.instanceFeatures.maxStatusChars let limit = mastodonController.instanceFeatures.maxStatusChars
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0 let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: mastodonController.instance)) return limit - (cwCount + CharacterCounter.count(text: draft.text, for: mastodonController.instance))
} }
private var requiresAttachmentDescriptions: Bool { var requiresAttachmentDescriptions: Bool {
guard Preferences.shared.requireAttachmentDescriptions else { return false } guard Preferences.shared.requireAttachmentDescriptions else { return false }
let attachmentIds = draft.attachments.map(\.id) let attachmentIds = draft.attachments.map(\.id)
return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) } return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) }
} }
private var postButtonEnabled: Bool { var postButtonEnabled: Bool {
draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions && (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty }) draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions && (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty })
} }
var body: some View { var body: some View {
bodyWithoutEnvironment
.environmentObject(uiState)
.environmentObject(mastodonController)
}
private var bodyWithoutEnvironment: some View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
mainList ScrollView(.vertical) {
mainStack
}
.coordinateSpace(name: ComposeView.coordinateSpaceOutsideOfScrollView)
.scrollDismissesKeyboardInteractivelyIfAvailable() .scrollDismissesKeyboardInteractivelyIfAvailable()
if let poster = poster { if let poster = poster {
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149 // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
WrappedProgressView(value: poster.currentStep, total: poster.totalSteps) WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
} }
}
.safeAreaInset(edge: .bottom, spacing: 0) {
if !uiState.isDucking {
VStack(spacing: 0) {
autocompleteSuggestions
.transition(.move(edge: .bottom))
.animation(.default, value: uiState.autocompleteState)
ComposeToolbar(draft: draft) autocompleteSuggestions
}
.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) .actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet)
.alert(isPresented: $isShowingPostErrorAlert) { .alert(isPresented: $isShowingPostErrorAlert) {
Alert( Alert(
@ -136,67 +109,52 @@ struct ComposeView: View {
} }
@ViewBuilder @ViewBuilder
private var autocompleteSuggestions: some View { var autocompleteSuggestions: some View {
VStack(spacing: 0) {
Spacer()
if let state = uiState.autocompleteState { if let state = uiState.autocompleteState {
ComposeAutocompleteView(autocompleteState: state) ComposeAutocompleteView(autocompleteState: state)
} }
} }
.transition(.move(edge: .bottom))
.animation(.default, value: uiState.autocompleteState)
}
private var mainList: some View { var mainStack: some View {
List { VStack(alignment: .leading, spacing: 8) {
if let id = draft.inReplyToID, if let id = draft.inReplyToID,
let status = mastodonController.persistentContainer.status(for: id) { let status = mastodonController.persistentContainer.status(for: id) {
ComposeReplyView( ComposeReplyView(
status: status, status: status,
rowTopInset: 8, stackPadding: stackPadding
globalFrameOutsideList: globalFrameOutsideList
) )
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
} }
header header
.listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
if draft.contentWarningEnabled { if draft.contentWarningEnabled {
ComposeEmojiTextField( ComposeEmojiTextField(text: $draft.contentWarning, placeholder: "Write your warning here")
text: $draft.contentWarning,
placeholder: "Write your warning here",
becomeFirstResponder: $contentWarningBecomeFirstResponder,
focusNextView: $mainComposeTextViewBecomeFirstResponder
)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
} }
MainComposeTextView( MainComposeTextView(
draft: draft, draft: draft
becomeFirstResponder: $mainComposeTextViewBecomeFirstResponder
) )
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
if let poll = draft.poll { if let poll = draft.poll {
ComposePollView(draft: draft, poll: poll) ComposePollView(draft: draft, poll: poll)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) .transition(.opacity.combined(with: .asymmetric(insertion: .scale(scale: 0.5, anchor: .leading), removal: .scale(scale: 0.5, anchor: .trailing))))
.listRowSeparator(.hidden)
} }
ComposeAttachmentsList( ComposeAttachmentsList(
draft: draft 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) .disabled(isPosting)
.onChange(of: draft.contentWarningEnabled) { newValue in .padding(stackPadding)
if newValue { .padding(.bottom, uiState.autocompleteState != nil ? 46 : nil)
contentWarningBecomeFirstResponder = true
}
}
} }
private var header: some View { private var header: some View {
@ -210,15 +168,6 @@ struct ComposeView: View {
}.frame(height: 50) }.frame(height: 50)
} }
private var navTitle: Text {
if let id = draft.inReplyToID,
let status = mastodonController.persistentContainer.status(for: id) {
return Text("Reply to @\(status.account.acct)")
} else {
return Text("New Post")
}
}
private var cancelButton: some View { private var cancelButton: some View {
Button(action: self.cancel) { Button(action: self.cancel) {
Text("Cancel") Text("Cancel")
@ -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 { //struct ComposeView_Previews: PreviewProvider {
// static var previews: some View { // static var previews: some View {
// ComposeView() // ComposeView()

View File

@ -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: ""))
// }
//}

View File

@ -17,10 +17,6 @@ struct MainComposeTextView: View {
if Date().formatted(date: .numeric, time: .omitted).starts(with: "3") { if Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
return Text("Happy π day!") return Text("Happy π day!")
} }
} else if components.month == 9 && components.day == 5 {
// https://weirder.earth/@noracodes/109276419847254552
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
return Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic()
} else if components.month == 9 && components.day == 21 { } else if components.month == 9 && components.day == 21 {
return Text("Do you remember?") return Text("Do you remember?")
} else if components.month == 10 && components.day == 31 { } else if components.month == 10 && components.day == 31 {
@ -35,7 +31,7 @@ struct MainComposeTextView: View {
let minHeight: CGFloat = 150 let minHeight: CGFloat = 150
@State private var height: CGFloat? @State private var height: CGFloat?
@Binding var becomeFirstResponder: Bool @State private var becomeFirstResponder: Bool = false
@State private var hasFirstAppeared = false @State private var hasFirstAppeared = false
@ScaledMetric private var fontSize = 20 @ScaledMetric private var fontSize = 20
@ -48,7 +44,6 @@ struct MainComposeTextView: View {
.font(.system(size: fontSize)) .font(.system(size: fontSize))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.offset(x: 4, y: 8) .offset(x: 4, y: 8)
.accessibilityHidden(true)
} }
MainComposeWrappedTextView( MainComposeWrappedTextView(
@ -79,7 +74,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
@EnvironmentObject var uiState: ComposeUIState @EnvironmentObject var uiState: ComposeUIState
@EnvironmentObject var mastodonController: MastodonController @EnvironmentObject var mastodonController: MastodonController
@Environment(\.isEnabled) var isEnabled: Bool
func makeUIView(context: Context) -> UITextView { func makeUIView(context: Context) -> UITextView {
let textView = WrappedTextView() let textView = WrappedTextView()
@ -100,8 +94,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
uiView.text = text uiView.text = text
} }
uiView.isEditable = isEnabled
context.coordinator.text = $text context.coordinator.text = $text
context.coordinator.didChange = textDidChange context.coordinator.didChange = textDidChange
context.coordinator.uiState = uiState context.coordinator.uiState = uiState
@ -172,16 +164,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
self.text = text self.text = text
self.didChange = didChange self.didChange = didChange
self.uiState = uiState self.uiState = uiState
super.init()
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil)
}
@objc private func keyboardDidShow() {
guard let textView,
textView.isFirstResponder else { return }
ensureCursorVisible(textView: textView)
} }
func textViewDidChange(_ textView: UITextView) { func textViewDidChange(_ textView: UITextView) {
@ -236,11 +218,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
if range.length > 0 { if range.length > 0 {
let formatMenu = suggestedActions[index] as! UIMenu let formatMenu = suggestedActions[index] as! UIMenu
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
var image: UIImage? UIAction(title: fmt.accessibilityLabel, image: fmt.image) { [weak self] _ in
if let imageName = fmt.imageName {
image = UIImage(systemName: imageName)
}
return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in
self?.applyFormat(fmt) self?.applyFormat(fmt)
} }
}) })

View File

@ -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)]
}
}

View File

@ -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>

View File

@ -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(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(reloadLists), name: .listsChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
} }
@ -180,7 +178,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
self.dataSource.apply(snapshot) self.dataSource.apply(snapshot)
} }
@objc private func reloadLists() { private func reloadLists() {
let request = Client.getLists() let request = Client.getLists()
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
guard case let .success(lists, _) = response else { guard case let .success(lists, _) = response else {
@ -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 @MainActor
private func fetchSavedHashtags() -> [SavedHashtag] { private func fetchSavedHashtags() -> [SavedHashtag] {
let req = SavedHashtag.fetchRequest() let req = SavedHashtag.fetchRequest()
@ -274,17 +255,29 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
} }
private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) { private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) {
Task { @MainActor in let titleFormat = NSLocalizedString("Are you sure you want to delete the '%@' list?", comment: "delete list alert title")
let service = DeleteListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) let title = String(format: titleFormat, list.title)
if await service.run() { let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
var snapshot = dataSource.snapshot() alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "delete list alert cancel button"), style: .cancel, handler: { (_) in
snapshot.deleteItems([.list(list)])
await dataSource.apply(snapshot)
completion(true)
} else {
completion(false) 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)])
DispatchQueue.main.async {
self.dataSource.apply(snapshot)
completion(true)
} }
} }
}))
present(alert, animated: true)
} }
func removeSavedHashtag(_ hashtag: Hashtag) { func removeSavedHashtag(_ hashtag: Hashtag) {
@ -363,12 +356,28 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
case .addList: case .addList:
collectionView.deselectItem(at: indexPath, animated: true) collectionView.deselectItem(at: indexPath, animated: true)
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true) }) { list in 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) let listTimelineController = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
listTimelineController.presentEditOnAppear = true listTimelineController.presentEditOnAppear = true
self.show(listTimelineController, sender: nil) self.show(listTimelineController, sender: nil)
} }
service.run() }
}))
present(alert, animated: true)
case let .savedHashtag(hashtag): case let .savedHashtag(hashtag):
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil) show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
@ -496,7 +505,7 @@ extension ExploreViewController {
case (.profileDirectory, .profileDirectory): case (.profileDirectory, .profileDirectory):
return true return true
case let (.list(a), .list(b)): case let (.list(a), .list(b)):
return a.id == b.id && a.title == b.title return a.id == b.id
case (.addList, .addList): case (.addList, .addList):
return true return true
case let (.savedHashtag(a), .savedHashtag(b)): case let (.savedHashtag(a), .savedHashtag(b)):
@ -527,7 +536,6 @@ extension ExploreViewController {
case let .list(list): case let .list(list):
hasher.combine("list") hasher.combine("list")
hasher.combine(list.id) hasher.combine(list.id)
hasher.combine(list.title)
case .addList: case .addList:
hasher.combine("addList") hasher.combine("addList")
case let .savedHashtag(hashtag): case let .savedHashtag(hashtag):

View File

@ -112,7 +112,7 @@ class ProfileDirectoryViewController: UIViewController {
private func updateProfiles() { private func updateProfiles() {
let scope = self.scope let scope = self.scope
let order = self.order let order = self.order
let local = scope == .instance let local = scope == .everywhere
let request = Client.getFeaturedProfiles(local: local, order: order) let request = Client.getFeaturedProfiles(local: local, order: order)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
guard case let .success(accounts, _) = response, guard case let .success(accounts, _) = response,

View File

@ -29,6 +29,8 @@ class FindInstanceViewController: InstanceSelectorTableViewController {
delegate = self delegate = self
searchController.hidesNavigationBarDuringPresentation = false
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed)) navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed))
} }

View File

@ -13,13 +13,13 @@ class EditListAccountsViewController: EnhancedTableViewController {
let mastodonController: MastodonController let mastodonController: MastodonController
private var list: List let list: List
var dataSource: DataSource! var dataSource: DataSource!
var nextRange: RequestRange? var nextRange: RequestRange?
var searchResultsController: EditListSearchResultsContainerViewController! var searchResultsController: SearchResultsViewController!
var searchController: UISearchController! var searchController: UISearchController!
init(list: List, mastodonController: MastodonController) { init(list: List, mastodonController: MastodonController) {
@ -28,9 +28,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
super.init(style: .plain) super.init(style: .plain)
listChanged() title = String(format: NSLocalizedString("Edit %@", comment: "edit list screen title"), list.title)
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: list.id)
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -55,23 +53,14 @@ class EditListAccountsViewController: EnhancedTableViewController {
}) })
dataSource.editListAccountsController = self dataSource.editListAccountsController = self
searchResultsController = EditListSearchResultsContainerViewController(mastodonController: mastodonController) { [unowned self] accountID in searchResultsController = SearchResultsViewController(mastodonController: mastodonController, resultTypes: [.accounts])
Task { searchResultsController.delegate = self
await self.addAccount(id: accountID)
}
}
searchController = UISearchController(searchResultsController: searchResultsController) searchController = UISearchController(searchResultsController: searchResultsController)
searchController.hidesNavigationBarDuringPresentation = false searchController.hidesNavigationBarDuringPresentation = false
searchController.searchResultsUpdater = searchResultsController searchController.searchResultsUpdater = searchResultsController
if #available(iOS 16.0, *) {
searchController.scopeBarActivation = .onSearchActivation
} else {
searchController.automaticallyShowsScopeBar = true
}
searchController.searchBar.autocapitalizationType = .none searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.placeholder = NSLocalizedString("Search for accounts to add", comment: "edit list search field placeholder") searchController.searchBar.placeholder = NSLocalizedString("Search for accounts to add", comment: "edit list search field placeholder")
searchController.searchBar.delegate = searchResultsController searchController.searchBar.delegate = searchResultsController
searchController.searchBar.scopeButtonTitles = ["Everyone", "People You Follow"]
definesPresentationContext = true definesPresentationContext = true
navigationItem.searchController = searchController navigationItem.searchController = searchController
@ -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)) navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed))
Task { loadAccounts()
await loadAccounts()
}
} }
private func listChanged() { func loadAccounts() {
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 request = List.getAccounts(list)
let (accounts, pagination) = try await mastodonController.run(request) mastodonController.run(request) { (response) in
guard case let .success(accounts, pagination) = response else {
fatalError()
}
self.nextRange = pagination?.older self.nextRange = pagination?.older
await withCheckedContinuation { continuation in self.mastodonController.persistentContainer.addAll(accounts: accounts) {
mastodonController.persistentContainer.addAll(accounts: accounts) {
continuation.resume()
}
}
var snapshot = self.dataSource.snapshot() var snapshot = self.dataSource.snapshot()
if snapshot.indexOfSection(.accounts) == nil { snapshot.deleteSections([.accounts])
snapshot.appendSections([.accounts]) snapshot.appendSections([.accounts])
} else {
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .accounts))
}
snapshot.appendItems(accounts.map { .account(id: $0.id) }) 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 { DispatchQueue.main.async {
do { self.dataSource.apply(snapshot)
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 // MARK: - Interaction
@objc func renameButtonPressed() { @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 return
} }
Task { let request = List.remove(editListAccountsController!.list, accounts: [id])
await self.editListAccountsController?.removeAccount(id: 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
} }
} }
} }

View File

@ -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 {
}

View File

@ -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)
}
}

View File

@ -11,7 +11,7 @@ import Pachyderm
class ListTimelineViewController: TimelineViewController { class ListTimelineViewController: TimelineViewController {
private(set) var list: List let list: List
var presentEditOnAppear = false var presentEditOnAppear = false
@ -20,9 +20,7 @@ class ListTimelineViewController: TimelineViewController {
super.init(for: .list(id: list.id), mastodonController: mastodonController) super.init(for: .list(id: list.id), mastodonController: mastodonController)
listChanged() title = list.title
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: list.id)
} }
required init?(coder aDecoder: NSCoder) { 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) { func presentEdit(animated: Bool) {
let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController) let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController)
editListAccountsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed)) editListAccountsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed))

View File

@ -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
}
}

View File

@ -99,8 +99,6 @@ class MainSidebarViewController: UIViewController {
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(reloadLists), name: .listsChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
onViewDidLoad?() onViewDidLoad?()
@ -203,7 +201,7 @@ class MainSidebarViewController: UIViewController {
} }
} }
@objc private func reloadLists() { private func reloadLists() {
let request = Client.getLists() let request = Client.getLists()
mastodonController.run(request) { [weak self] (response) in mastodonController.run(request) { [weak self] (response) in
guard let self = self, case let .success(lists, _) = response else { return } guard let self = self, case let .success(lists, _) = response else { return }
@ -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 @MainActor
private func fetchSavedHashtags() -> [SavedHashtag] { private func fetchSavedHashtags() -> [SavedHashtag] {
let req = SavedHashtag.fetchRequest() let req = SavedHashtag.fetchRequest()
@ -316,12 +297,28 @@ class MainSidebarViewController: UIViewController {
} }
} }
// todo: deduplicate with ExploreViewController
private func showAddList() { private func showAddList() {
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true let alert = UIAlertController(title: "New List", message: "Choose a title for your new list", preferredStyle: .alert)
) }) { list in 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)) self.sidebarDelegate?.sidebar(self, didSelectItem: .list(list))
} }
service.run() }
}))
present(alert, animated: true)
} }
// todo: deduplicate with ExploreViewController // todo: deduplicate with ExploreViewController
@ -554,22 +551,11 @@ extension MainSidebarViewController: UICollectionViewDelegate {
} }
activity.displaysAuxiliaryScene = true activity.displaysAuxiliaryScene = true
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) in return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) in
var actions: [UIAction] = [ return UIMenu(children: [
UIWindowScene.ActivationAction({ action in UIWindowScene.ActivationAction({ action in
return UIWindowScene.ActivationConfiguration(userActivity: activity) return UIWindowScene.ActivationConfiguration(userActivity: activity)
}), }),
] ])
if case .list(let list) = item {
actions.append(UIAction(title: "Delete List", image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [unowned self] _ in
Task {
let service = DeleteListService(list: list, mastodonController: self.mastodonController, present: { self.present($0, animated: true) })
await service.run()
}
}))
}
return UIMenu(children: actions)
} }
} }
} }

View File

@ -373,13 +373,19 @@ fileprivate extension MainSidebarViewController.Item {
} }
} }
extension MainSplitViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension MainSplitViewController: TuskerRootViewController { extension MainSplitViewController: TuskerRootViewController {
@objc func presentCompose() { @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) { func select(tab: MainTabBarViewController.Tab) {

View File

@ -228,13 +228,19 @@ extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate {
} }
} }
extension MainTabBarViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension MainTabBarViewController: TuskerRootViewController { extension MainTabBarViewController: TuskerRootViewController {
@objc func presentCompose() { @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) { func select(tab: Tab) {

View File

@ -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()
// }
//}

View File

@ -63,7 +63,6 @@ class InstanceSelectorTableViewController: UITableViewController {
appearance.configureWithDefaultBackground() appearance.configureWithDefaultBackground()
navigationItem.scrollEdgeAppearance = appearance navigationItem.scrollEdgeAppearance = appearance
tableView.keyboardDismissMode = .interactive
tableView.register(UINib(nibName: "InstanceTableViewCell", bundle: .main), forCellReuseIdentifier: instanceCell) tableView.register(UINib(nibName: "InstanceTableViewCell", bundle: .main), forCellReuseIdentifier: instanceCell)
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120 tableView.estimatedRowHeight = 120
@ -85,11 +84,7 @@ class InstanceSelectorTableViewController: UITableViewController {
searchController = UISearchController(searchResultsController: nil) searchController = UISearchController(searchResultsController: nil)
searchController.searchResultsUpdater = self searchController.searchResultsUpdater = self
searchController.obscuresBackgroundDuringPresentation = false searchController.obscuresBackgroundDuringPresentation = false
searchController.hidesNavigationBarDuringPresentation = false
searchController.searchBar.searchTextField.autocapitalizationType = .none searchController.searchBar.searchTextField.autocapitalizationType = .none
searchController.searchBar.searchTextField.keyboardType = .URL
searchController.searchBar.showsCancelButton = false
searchController.searchBar.placeholder = "Search or enter a URL"
navigationItem.searchController = searchController navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false navigationItem.hidesSearchBarWhenScrolling = false
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {

View File

@ -31,7 +31,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private(set) var headerCell: ProfileHeaderCollectionViewCell? private(set) var headerCell: ProfileHeaderCollectionViewCell?
private(set) var state: State = .unloaded private var state: State = .unloaded
init(accountID: String?, kind: Kind, owner: ProfileViewController) { init(accountID: String?, kind: Kind, owner: ProfileViewController) {
self.accountID = accountID self.accountID = accountID
@ -99,6 +99,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
Task { Task {
await load() await load()
} }
case .loading:
break
case .loaded, .setupInitialSnapshot: case .loaded, .setupInitialSnapshot:
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([.header(id)]) snapshot.reconfigureItems([.header(id)])
@ -131,8 +133,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
view.updateUI(for: id) view.updateUI(for: id)
view.pagesSegmentedControl.selectedSegmentIndex = self.owner?.currentIndex ?? 0 view.pagesSegmentedControl.selectedSegmentIndex = self.owner?.currentIndex ?? 0
cell.addHeader(view) cell.addHeader(view)
case .useExistingView(let view):
cell.addHeader(view)
case .placeholder(height: let height): case .placeholder(height: let height):
_ = cell.addConstraint(height: height) _ = cell.addConstraint(height: height)
} }
@ -174,11 +174,13 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
private func load() async { private func load() async {
guard isViewLoaded, guard isViewLoaded,
let accountID, let accountID,
state == .unloaded, case .unloaded = state,
mastodonController.persistentContainer.account(for: accountID) != nil else { mastodonController.persistentContainer.account(for: accountID) != nil else {
return return
} }
state = .loading
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.header, .pinned, .statuses]) snapshot.appendSections([.header, .pinned, .statuses])
snapshot.appendItems([.header(accountID)], toSection: .header) snapshot.appendItems([.header(accountID)], toSection: .header)
@ -190,9 +192,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
await tryLoadPinned() await tryLoadPinned()
state = .loaded state = .loaded
// remove any content inset that was added when switching pages to this VC
collectionView.contentInset = .zero
} }
private func tryLoadPinned() async { private func tryLoadPinned() async {
@ -261,6 +260,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
extension ProfileStatusesViewController { extension ProfileStatusesViewController {
enum State { enum State {
case unloaded case unloaded
case loading
case setupInitialSnapshot case setupInitialSnapshot
case loaded case loaded
} }
@ -271,7 +271,7 @@ extension ProfileStatusesViewController {
case statuses, withReplies, onlyMedia case statuses, withReplies, onlyMedia
} }
enum HeaderMode { enum HeaderMode {
case createView, useExistingView(ProfileHeaderView), placeholder(height: CGFloat) case createView, placeholder(height: CGFloat)
} }
} }

View File

@ -10,7 +10,7 @@ import UIKit
import Pachyderm import Pachyderm
import Combine import Combine
class ProfileViewController: UIViewController { class ProfileViewController: UIPageViewController {
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
@ -42,7 +42,7 @@ class ProfileViewController: UIViewController {
self.accountID = accountID self.accountID = accountID
self.mastodonController = mastodonController self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil) super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
self.pageControllers = [ self.pageControllers = [
.init(accountID: accountID, kind: .statuses, owner: self), .init(accountID: accountID, kind: .statuses, owner: self),
@ -146,32 +146,26 @@ class ProfileViewController: UIViewController {
state = .animating 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 { guard let old = viewControllers?.first as? ProfileStatusesViewController else {
assert(!animated)
// if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary // if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary
new.initialHeaderMode = .createView pageControllers[index].initialHeaderMode = .createView
new.view.translatesAutoresizingMaskIntoConstraints = false setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in
embedChild(new) self.state = .idle
self.currentIndex = index completion?(finished)
state = .idle }
completion?(true) currentIndex = index
return return
} }
let new = pageControllers[index]
let direction: CGFloat currentIndex = index
if index - currentIndex > 0 {
direction = 1 // forward
} else {
direction = -1 // reverse
}
let old = pageControllers[currentIndex]
new.loadViewIfNeeded()
self.currentIndex = index
// TODO: old.headerCell could be nil if scrolled down and key command used // TODO: old.headerCell could be nil if scrolled down and key command used
let oldHeaderCell = old.headerCell! let oldHeaderCell = old.headerCell!
@ -179,8 +173,8 @@ class ProfileViewController: UIViewController {
// old header cell must have the header view // old header cell must have the header view
let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)! let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)!
if let newHeaderCell = new.headerCell { if new.isViewLoaded {
_ = newHeaderCell.addConstraint(height: oldHeaderCell.bounds.height) _ = new.headerCell!.addConstraint(height: oldHeaderCell.bounds.height)
} else { } else {
new.initialHeaderMode = .placeholder(height: oldHeaderCell.bounds.height) 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 // hide scroll indicators during the transition because otherwise the show through the
// profile header, even though it has an opaque background // profile header, even though it has an opaque background
old.collectionView.showsVerticalScrollIndicator = false old.collectionView.showsVerticalScrollIndicator = false
if new.isViewLoaded {
new.collectionView.showsVerticalScrollIndicator = false new.collectionView.showsVerticalScrollIndicator = false
}
// 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 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 old.collectionView.contentOffset = CGPoint(x: 0, y: view.safeAreaInsets.top)
// and new's offset doesn't physically match old's, even though they're numerically the same
let needsMatchContentOffsetWithTransform = new.state != .loaded snapshot.frame = old.collectionView.bounds
let yTranslationToMatchOldContentOffset: CGFloat snapshot.frame.origin.y = 0
if needsMatchContentOffsetWithTransform { snapshot.layer.zPosition = 99
yTranslationToMatchOldContentOffset = -origOldContentOffset.y - view.safeAreaInsets.top view.addSubview(snapshot)
} else {
new.collectionView.contentOffset = origOldContentOffset // empirically, 0.3s seems to match the UIPageViewController animation
yTranslationToMatchOldContentOffset = 0 UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) {
// animate the snapshot offscreen in the same direction as the old view
snapshot.frame.origin.x = direction == .forward ? -self.view.bounds.width : self.view.bounds.width
// animate the snapshot to be "scrolled" to top
snapshot.frame.origin.y = self.view.safeAreaInsets.top + origOldContentOffset.y
// if scrolling because the new collection view's content isn't tall enough, make sure to scroll it to top as well
if new.isViewLoaded {
new.collectionView.contentOffset = CGPoint(x: 0, y: -self.view.safeAreaInsets.top)
}
headerView.transform = CGAffineTransform(translationX: 0, y: -headerTopOffset)
} completion: { _ in
snapshot.removeFromSuperview()
}
} else if new.isViewLoaded {
new.collectionView.contentOffset = old.collectionView.contentOffset
} }
if animated { setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in
// 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)
}
new.view.translatesAutoresizingMaskIntoConstraints = false
embedChild(new)
new.view.transform = CGAffineTransform(translationX: direction * view.bounds.width, y: yTranslationToMatchOldContentOffset)
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 // reenable scroll indicators after the switching animation is done
old.collectionView.showsVerticalScrollIndicator = true old.collectionView.showsVerticalScrollIndicator = true
new.collectionView.showsVerticalScrollIndicator = true new.collectionView.showsVerticalScrollIndicator = true
headerView.isUserInteractionEnabled = true headerView.isUserInteractionEnabled = true
headerView.transform = .identity headerView.transform = .identity
headerView.layer.zPosition = 0 headerView.layer.zPosition = 0
// move the header view into the new page controller's cell // move the header view into the new page controller's cell
if let newHeaderCell = new.headerCell { // 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
newHeaderCell.addHeader(headerView) new.headerCell!.addHeader(headerView)
} else {
new.initialHeaderMode = .useExistingView(headerView)
}
self.state = .idle self.state = .idle
completion?(true) completion?(finished)
}
animator.startAnimation()
} else {
old.removeViewAndController()
new.view.translatesAutoresizingMaskIntoConstraints = false
embedChild(new)
completion?(true)
} }
} }

View File

@ -197,11 +197,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
@objc func refresh() { @objc func refresh() {
Task { Task {
if case .notLoadedInitial = await controller.state {
await controller.loadInitial()
} else {
await controller.loadNewer() await controller.loadNewer()
}
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing() collectionView.refreshControl?.endRefreshing()
#endif #endif
@ -284,8 +280,6 @@ extension TimelineViewController {
typealias TimelineItem = String // status ID typealias TimelineItem = String // status ID
func loadInitial() async throws -> [TimelineItem] { func loadInitial() async throws -> [TimelineItem] {
try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
guard let mastodonController else { guard let mastodonController else {
throw Error.noClient throw Error.noClient
} }

View File

@ -76,7 +76,6 @@ class CustomAlertController: UIViewController {
let titleLabel = UILabel() let titleLabel = UILabel()
titleLabel.text = config.title titleLabel.text = config.title
titleLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0) titleLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.numberOfLines = 0 titleLabel.numberOfLines = 0
titleLabel.textAlignment = .center titleLabel.textAlignment = .center
stack.addArrangedSubview(titleLabel) stack.addArrangedSubview(titleLabel)
@ -362,14 +361,13 @@ class CustomAlertActionButton: UIControl {
let label = UILabel() let label = UILabel()
label.text = title label.text = title
label.textColor = .tintColor label.textColor = .tintColor
label.adjustsFontForContentSizeCategory = true switch action.style {
if case .cancel = action.style { case .cancel:
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0) label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0)
} else { case .destructive:
label.font = .preferredFont(forTextStyle: .body)
}
if case .destructive = action.style {
label.textColor = .systemRed label.textColor = .systemRed
default:
break
} }
titleView.addArrangedSubview(label) titleView.addArrangedSubview(label)
} }

View File

@ -10,7 +10,6 @@ import UIKit
import SafariServices import SafariServices
import Pachyderm import Pachyderm
import WebURLFoundationExtras import WebURLFoundationExtras
import SwiftUI
protocol MenuActionProvider: AnyObject { protocol MenuActionProvider: AnyObject {
var navigationDelegate: TuskerNavigationDelegate? { get } var navigationDelegate: TuskerNavigationDelegate? { get }
@ -43,6 +42,46 @@ extension MenuActionProvider {
guard let mastodonController = mastodonController, guard let mastodonController = mastodonController,
let account = mastodonController.persistentContainer.account(for: accountID) else { return [] } let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
guard let loggedInAccountID = mastodonController.accountInfo?.id else {
// user is logged out
return [
openInSafariAction(url: account.url),
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView)
})
]
}
let actionsSection: [UIMenuElement] = [
createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { [weak self] (_) in
guard let self = self else { return }
let draft = self.mastodonController!.createDraft(mentioningAcct: account.acct)
draft.visibility = .direct
self.navigationDelegate?.compose(editing: draft)
}),
UIDeferredMenuElement.uncached({ @MainActor [unowned self] elementHandler in
let relationship = Task {
await fetchRelationship(accountID: accountID, mastodonController: mastodonController)
}
// workaround for #198, may result in showing outdated relationship, so only do so where necessary
if ProcessInfo.processInfo.isiOSAppOnMac,
let mo = mastodonController.persistentContainer.relationship(forAccount: accountID),
let action = self.followAction(for: mo, mastodonController: mastodonController) {
elementHandler([action])
} else {
Task { @MainActor in
if let relationship = await relationship.value,
let action = self.followAction(for: relationship, mastodonController: mastodonController) {
elementHandler([action])
} else {
elementHandler([])
}
}
}
})
]
var shareSection = [ var shareSection = [
openInSafariAction(url: account.url), openInSafariAction(url: account.url),
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
@ -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)) addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showProfileActivity(id: accountID, accountID: loggedInAccountID))
return [ return [
UIMenu(options: .displayInline, children: shareSection), UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
UIMenu(options: .displayInline, children: actionsSection), UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: actionsSection),
UIMenu(options: .displayInline, children: suppressSection),
] ]
} }
@ -149,7 +166,12 @@ extension MenuActionProvider {
self.mastodonController?.persistentContainer.addOrUpdate(status: status) self.mastodonController?.persistentContainer.addOrUpdate(status: status)
case .failure(let error): 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, _): case .success(let status, _):
self.mastodonController?.persistentContainer.addOrUpdate(status: status) self.mastodonController?.persistentContainer.addOrUpdate(status: status)
case .failure(let error): 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, _): case .success(let status, _):
self.mastodonController?.persistentContainer.addOrUpdate(status: status) self.mastodonController?.persistentContainer.addOrUpdate(status: status)
case .failure(let error): 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) mastodonController.persistentContainer.addOrUpdate(status: status, context: mastodonController.persistentContainer.viewContext)
} }
case .failure(let error): 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) }), 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 @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 accountID = relationship.accountID
let following = relationship.following let following = relationship.following
return createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus") { [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) let request = (following ? Account.unfollow : Account.follow)(accountID)
mastodonController.run(request) { response in mastodonController.run(request) { response in
switch response { switch response {
case .failure(let error): case .failure(let error):
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, _): case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: 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? { private func fetchRelationship(accountID: String, mastodonController: MastodonController) async -> RelationshipMO? {

View File

@ -64,14 +64,6 @@ extension TimelineLikeCollectionViewController {
} }
func handleAddLoadingIndicator() async { func handleAddLoadingIndicator() async {
if case .loadingInitial(_, _) = await controller.state,
let refreshControl = collectionView.refreshControl,
refreshControl.isRefreshing {
refreshControl.beginRefreshing()
// if we're loading initial and the refresh control is already going, we don't need to add another indicator
return
}
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
if !snapshot.sectionIdentifiers.contains(.footer) { if !snapshot.sectionIdentifiers.contains(.footer) {
snapshot.appendSections([.footer]) snapshot.appendSections([.footer])
@ -85,13 +77,6 @@ extension TimelineLikeCollectionViewController {
} }
func handleRemoveLoadingIndicator() async { func handleRemoveLoadingIndicator() async {
if case .loadingInitial(_, _) = await controller.state,
let refreshControl = collectionView.refreshControl,
refreshControl.isRefreshing {
refreshControl.endRefreshing()
return
}
let oldContentOffset = collectionView.contentOffset let oldContentOffset = collectionView.contentOffset
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.deleteSections([.footer]) snapshot.deleteSections([.footer])

View File

@ -70,7 +70,7 @@ actor TimelineLikeController<Item> {
} catch { } catch {
await loadingIndicator.end() await loadingIndicator.end()
await emit(event: .loadAllError(error, token)) await emit(event: .loadAllError(error, token))
state = .notLoadedInitial state = .idle
} }
} }
@ -194,7 +194,7 @@ actor TimelineLikeController<Item> {
return false return false
} }
case .loadingInitial(let token, let hasAddedLoadingIndicator): case .loadingInitial(let token, let hasAddedLoadingIndicator):
return to == .notLoadedInitial || to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true)) return to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true))
case .loadingNewer(_): case .loadingNewer(_):
return to == .idle return to == .idle
case .loadingOlder(let token, let hasAddedLoadingIndicator): case .loadingOlder(let token, let hasAddedLoadingIndicator):

View File

@ -94,11 +94,6 @@ extension TuskerNavigationDelegate {
let options = UIWindowScene.ActivationRequestOptions() let options = UIWindowScene.ActivationRequestOptions()
options.preferredPresentationStyle = .prominent options.preferredPresentationStyle = .prominent
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil) 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 { } else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController) let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
let nav = UINavigationController(rootViewController: compose) let nav = UINavigationController(rootViewController: compose)
@ -106,7 +101,6 @@ extension TuskerNavigationDelegate {
present(nav, animated: true) present(nav, animated: true)
} }
} }
}
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) { func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) {
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct) let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)

View File

@ -11,9 +11,11 @@ import Foundation
struct ViewTags { struct ViewTags {
private init() {} private init() {}
static let navBackBarButton = 42001 static let composeVisibilityBarButton = 42001
static let navForwardBarButton = 42002 static let composeLocalOnlyBarButton = 42002
static let navEmptyTitleView = 42003 static let navBackBarButton = 42003
static let splitNavCloseSecondaryButton = 42004 static let navForwardBarButton = 42004
static let customAlertSeparator = 42005 static let navEmptyTitleView = 42005
static let splitNavCloseSecondaryButton = 42006
static let customAlertSeparator = 42007
} }

View File

@ -69,7 +69,6 @@ class AccountTableViewCell: UITableViewCell {
let accountID = self.accountID let accountID = self.accountID
avatarImageView.image = nil
if let avatarURL = account.avatar { if let avatarURL = account.avatar {
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self else { return } guard let self = self else { return }

View File

@ -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))
}
}

View File

@ -55,7 +55,6 @@ class ConfirmReblogStatusPreviewView: UIView {
let displayNameLabel = EmojiLabel() let displayNameLabel = EmojiLabel()
displayNameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .caption1).addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold]]), size: 0) displayNameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .caption1).addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold]]), size: 0)
displayNameLabel.adjustsFontSizeToFitWidth = true displayNameLabel.adjustsFontSizeToFitWidth = true
displayNameLabel.adjustsFontForContentSizeCategory = true
displayNameLabel.updateForAccountDisplayName(account: status.account) displayNameLabel.updateForAccountDisplayName(account: status.account)
vStack.addArrangedSubview(displayNameLabel) vStack.addArrangedSubview(displayNameLabel)
@ -65,7 +64,6 @@ class ConfirmReblogStatusPreviewView: UIView {
contentView.isScrollEnabled = false contentView.isScrollEnabled = false
contentView.backgroundColor = nil contentView.backgroundColor = nil
contentView.textContainerInset = .zero contentView.textContainerInset = .zero
contentView.adjustsFontForContentSizeCategory = true
// remove the extra line spacing applied by StatusContentTextView because, since we're using a smaller font, the regular 2pt looks big // remove the extra line spacing applied by StatusContentTextView because, since we're using a smaller font, the regular 2pt looks big
contentView.paragraphStyle = .default contentView.paragraphStyle = .default
// TODO: line limit // TODO: line limit

View File

@ -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() }
}
}

View File

@ -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>

View File

@ -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"),
])
}
}

View File

@ -18,14 +18,7 @@ class ProfileFieldsView: UIView {
private var isUsingSingleColumn: Bool = false private var isUsingSingleColumn: Bool = false
private var needsSingleColumn: Bool { private var needsSingleColumn: Bool {
traitCollection.horizontalSizeClass == .compact && traitCollection.preferredContentSizeCategory > .extraLarge traitCollection.preferredContentSizeCategory > .large
}
override var accessibilityElements: [Any]? {
get {
fieldViews.flatMap { [$0.0, $0.1] }
}
set {}
} }
override init(frame: CGRect) { override init(frame: CGRect) {

View File

@ -142,14 +142,14 @@ class ProfileHeaderView: UIView {
} }
} }
fieldsView.delegate = delegate
fieldsView.updateUI(account: account) fieldsView.updateUI(account: account)
accessibilityElements = [ accessibilityElements = [
displayNameLabel!, displayNameLabel!,
usernameLabel!, usernameLabel!,
noteTextView!, noteTextView!,
fieldsView!, // TODO: voiceover for fieldsview
// fieldsView!,
moreButton!, moreButton!,
pagesSegmentedControl!, pagesSegmentedControl!,
] ]

View File

@ -321,67 +321,32 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
// MARK: Accessibility // MARK: Accessibility
override var isAccessibilityElement: Bool { override var accessibilityLabel: String? {
get { true }
set {}
}
override var accessibilityAttributedLabel: NSAttributedString? {
get { get {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { guard let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil return nil
} }
var str = AttributedString("\(status.account.displayOrUserName), ") var str = "\(status.account.displayOrUserName), \(contentTextView.text ?? "")"
if statusState.collapsed ?? false {
if !status.spoilerText.isEmpty {
str += AttributedString(status.spoilerText)
str += ", "
}
str += "collapsed"
} else {
str += AttributedString(contentTextView.attributedText)
}
if status.attachments.count > 0 { if status.attachments.count > 0 {
// TODO: localize me // TODO: localize me
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")") str += ", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")"
} }
if status.poll != nil { if status.poll != nil {
str += ", poll" str += ", poll"
} }
str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))") str += ", \(status.createdAt.formatted(.relative(presentation: .numeric)))"
if status.visibility < .unlisted {
str += AttributedString(", \(status.visibility.displayName)")
}
if status.localOnly {
str += ", local"
}
if let rebloggerID, if let rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) { let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
str += AttributedString(", reblogged by \(reblogger.displayOrUserName)") str += ", reblogged by \(reblogger.displayOrUserName)"
}
return NSAttributedString(str)
}
set {}
}
override var accessibilityHint: String? {
get {
if statusState.collapsed ?? false {
return "Double tap to expand the post."
} else {
return nil
} }
return str
} }
set {} set {}
} }
override func accessibilityActivate() -> Bool { override func accessibilityActivate() -> Bool {
if statusState.collapsed ?? false {
toggleCollapse()
} else {
delegate?.selected(status: statusID, state: statusState.copy()) delegate?.selected(status: statusID, state: statusState.copy())
}
return true return true
} }

View File

@ -249,62 +249,33 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
// MARK: - Accessibility // MARK: - Accessibility
override var accessibilityAttributedLabel: NSAttributedString? { override var accessibilityLabel: String? {
get { get {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { guard let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil return nil
} }
var str = AttributedString("\(status.account.displayOrUserName), ") var str = "\(status.account.displayName), \(contentTextView.text ?? "")"
if statusState.collapsed ?? false {
if !status.spoilerText.isEmpty {
str += AttributedString(status.spoilerText)
str += ", "
}
str += "collapsed"
} else {
str += AttributedString(contentTextView.attributedText)
}
if status.attachments.count > 0 { if status.attachments.count > 0 {
// TODO: localize me // todo: localize me
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")") str += ", \(status.attachments.count) attachments"
} }
if status.poll != nil { if status.poll != nil {
str += ", poll" str += ", poll"
} }
str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))") str += ", \(TimelineStatusTableViewCell.relativeDateFormatter.localizedString(for: status.createdAt, relativeTo: Date()))"
if status.visibility < .unlisted { if let rebloggerID = rebloggerID,
str += AttributedString(", \(status.visibility.displayName)")
}
if status.localOnly {
str += ", local"
}
if let rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) { let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
str += AttributedString(", reblogged by \(reblogger.displayOrUserName)") str += ", reblogged by \(reblogger.displayName)"
}
return NSAttributedString(str)
}
set {}
} }
override var accessibilityHint: String? { return str
get {
if statusState.collapsed ?? false {
return "Double tap to expand the post."
} else {
return nil
}
} }
set {} set {}
} }
override func accessibilityActivate() -> Bool { override func accessibilityActivate() -> Bool {
if statusState.collapsed ?? false {
collapseButtonPressed()
} else {
didSelectCell() didSelectCell()
}
return true return true
} }