Compare commits

..

45 Commits

Author SHA1 Message Date
Shadowfacts dd82283341 Bump build number and update changelog 2022-11-13 18:40:40 -05:00
Shadowfacts af2d9e7eb8 Fix pleroma version detection 2022-11-13 18:24:46 -05:00
Shadowfacts 06ad46e639 Fix confirm reblog alert not adjusting to Dynamic Type
Closes #246
2022-11-13 17:15:06 -05:00
Shadowfacts 71f97d41c4 Fix certain instance features not being detected properly 2022-11-13 17:08:15 -05:00
Shadowfacts df131f32c6 Fix reblog visibility dropdown displaying even when unsupported 2022-11-13 17:07:57 -05:00
Shadowfacts 77dece36d0 Fix Hometown versions not being parsed correctly 2022-11-13 17:05:08 -05:00
Shadowfacts 1a767ff910 Fix crash when opening My Profile on iPad 2022-11-13 14:30:00 -05:00
Shadowfacts 220c8050b1 Re-add pointer effects to Compose toolbar buttons 2022-11-13 14:15:44 -05:00
Shadowfacts d4fa9c96e8 Add context menu action to delete draft 2022-11-13 14:03:51 -05:00
Shadowfacts 22b5d62ba1 Make GIF attachments animate in the Compose screen 2022-11-13 14:01:54 -05:00
Shadowfacts b9bdd29986 Fix GIFs dragged from Finder posting as static images
Closes #239
2022-11-13 13:46:19 -05:00
Shadowfacts f848bbf7c4 Remove unneeded ComposeContainerView 2022-11-12 22:59:11 -05:00
Shadowfacts 0fe9edfdbc Fix crash when opening Drafts screen on macOS 2022-11-12 22:59:11 -05:00
Shadowfacts 6d2830cf78 Rewrite Compose toolbar with SwiftUI
Fixes buttons not being accessible with VoiceOver
Fixes content overflowing on small devices

Closes #232
Closes #218
2022-11-12 22:59:11 -05:00
Shadowfacts 7294ff6e1a Status VoiceOver improvements
Closes #229
Closes #230
2022-11-12 15:17:30 -05:00
Shadowfacts 3fd62552b3 Hide redundant info from VoiceOver in mute screen 2022-11-12 14:45:30 -05:00
Shadowfacts fa5abc27f7 Make profile fields view VoiceOver accessible 2022-11-12 14:43:47 -05:00
Shadowfacts ccc47e204d Fix InstanceFeatures not correctly using pleroma version 2022-11-12 14:34:57 -05:00
Shadowfacts bf3f735062 Focus CW field immediately when CW enabled, move focus to main text view when return key pressed
Closes #226
2022-11-12 14:16:05 -05:00
Shadowfacts de0198946e Fix keyboard reappearing after pressing Post button on Compose screen 2022-11-12 13:52:36 -05:00
Shadowfacts 072a77b58e Cleanup previewing actions code 2022-11-11 23:35:30 -05:00
Shadowfacts eb7fe22863 Add mute action to profiles
Closes #201
2022-11-11 23:35:30 -05:00
Shadowfacts f1511039ef Add domain block action to profiles 2022-11-11 22:44:58 -05:00
Shadowfacts 5c479e3bf0 Convert wide-gamut images to sRGB before uploading 2022-11-11 21:02:38 -05:00
Shadowfacts 0413f326a0 Add block action to accounts
Closes #208
2022-11-11 19:09:34 -05:00
Shadowfacts 9d1c3f1410 Fix error when decoding notification that has a status field but is null 2022-11-11 18:48:58 -05:00
Shadowfacts 802a0ac9ba Fix scope selector in Profile Directory being flipped 2022-11-11 18:30:09 -05:00
Shadowfacts 9da986e3b8 Tweak heuristic for showing profile fields in single column 2022-11-11 18:26:59 -05:00
Shadowfacts e6a5b899be Add context menu action for deleting lists on iPad 2022-11-11 18:20:16 -05:00
Shadowfacts 60bf3b2e33 Fix potential crash when deleting list 2022-11-11 18:16:44 -05:00
Shadowfacts b465838b71 Fix renaming list not updating UI
Closes #213
2022-11-11 18:08:44 -05:00
Shadowfacts 21bd716844 Fix crash when creating list fails
Closes #212
2022-11-11 17:54:25 -05:00
Shadowfacts 523fb91b21 Add scope to search following accounts when editing list
Also fixes crash when loading or editing list

Closes #216
Closes #221
2022-11-11 17:33:48 -05:00
Shadowfacts d8bf770902 Instance selector tweaks
Closes #234
Closes #237
2022-11-10 17:05:51 -05:00
Shadowfacts 10aa32d9cc Don't use UIPageViewController for profiles
Closes #228
2022-11-10 17:00:46 -05:00
Shadowfacts 7474969969 Workaround for AVPlayerViewController controls not respecting safe area
Closes #176
2022-11-09 21:46:52 -05:00
Shadowfacts 319b5458fc Fix refreshing not loading initial when previous attempt failed
Closes #214
2022-11-09 19:15:08 -05:00
Shadowfacts f7304a011c Fix images not being cached
Fixes #219
2022-11-09 18:56:59 -05:00
Shadowfacts 94dc5d3177 Fix not being able to tap links in profile fields
Closes #211
2022-11-09 18:51:27 -05:00
Shadowfacts 6d692c2730 Rewrite Drafts screen with SwiftUI 2022-11-09 18:18:31 -05:00
Shadowfacts d0f8691560 Fix draft cells become untappably small 2022-11-09 17:20:56 -05:00
Shadowfacts 9a43ab5a13 Fix caret not scrolling into view when focusing compose text views
Closes #233
2022-11-09 17:18:17 -05:00
Shadowfacts 01124b76a3 Add Duckable package, make Compose screen duckable 2022-11-08 22:17:01 -05:00
Shadowfacts 7600954f4b Refactor ComposeView to use a single List for everything 2022-11-07 22:58:01 -05:00
Shadowfacts 5a5c67e445 Try to prevent pruning accounts that still have statuses referencing them 2022-11-07 18:47:46 -05:00
80 changed files with 2702 additions and 1258 deletions

View File

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

View File

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

View File

@ -30,11 +30,7 @@ public class Notification: Decodable {
}
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.account = try container.decode(Account.self, forKey: .account)
if container.contains(.status) {
self.status = try container.decode(Status.self, forKey: .status)
} else {
self.status = nil
}
self.status = try container.decodeIfPresent(Status.self, forKey: .status)
}
public static func dismiss(id notificationID: String) -> Request<Empty> {

9
Packages/Duckable/.gitignore vendored Normal file
View File

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

View File

@ -0,0 +1,31 @@
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "Duckable",
platforms: [
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "Duckable",
targets: ["Duckable"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "Duckable",
dependencies: []),
.testTarget(
name: "DuckableTests",
dependencies: ["Duckable"]),
]
)

View File

@ -0,0 +1,3 @@
# Duckable
A package that allows modally-presented view controllers to be 'ducked' to make the content behind them accessible (à la Mail.app).

View File

@ -0,0 +1,44 @@
//
// API.swift
// Duckable
//
// Created by Shadowfacts on 11/7/22.
//
import UIKit
public protocol DuckableViewController: UIViewController {
var duckableDelegate: DuckableViewControllerDelegate? { get set }
func duckableViewControllerMayAttemptToDuck()
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat)
func duckableViewControllerDidFinishAnimatingDuck()
}
extension DuckableViewController {
public func duckableViewControllerMayAttemptToDuck() {}
public func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {}
public func duckableViewControllerDidFinishAnimatingDuck() {}
}
public protocol DuckableViewControllerDelegate: AnyObject {
func duckableViewControllerWillDismiss(animated: Bool)
}
extension UIViewController {
@available(iOS 16.0, *)
public func presentDuckable(_ viewController: DuckableViewController) -> Bool {
var cur: UIViewController? = self
while let vc = cur {
if let container = vc as? DuckableContainerViewController {
container.presentDuckable(viewController, animated: true, completion: nil)
return true
} else {
cur = vc.parent
}
}
return false
}
}

View File

@ -0,0 +1,12 @@
//
// DetentIdentifier.swift
// Duckable
//
// Created by Shadowfacts on 11/7/22.
//
import UIKit
extension UISheetPresentationController.Detent.Identifier {
static let bottom = Self("\(Bundle.main.bundleIdentifier!).bottom")
}

View File

@ -0,0 +1,89 @@
//
// DuckAnimationController.swift
// Duckable
//
// Created by Shadowfacts on 11/7/22.
//
import UIKit
@available(iOS 16.0, *)
class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
let owner: DuckableContainerViewController
let needsShrinkAnimation: Bool
init(owner: DuckableContainerViewController, needsShrinkAnimation: Bool) {
self.owner = owner
self.needsShrinkAnimation = needsShrinkAnimation
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.2
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard case .ducked(let duckable, placeholder: let placeholder) = owner.state,
let presented = transitionContext.viewController(forKey: .from) else {
transitionContext.completeTransition(false)
return
}
guard transitionContext.isAnimated else {
transitionContext.completeTransition(true)
return
}
let container = transitionContext.containerView
if needsShrinkAnimation {
duckable.duckableViewControllerWillAnimateDuck(withDuration: 0.2, afterDelay: 0.2)
let presentedFrameInContainer = container.convert(presented.view.bounds, from: presented.view)
let heightToSlide = container.bounds.height - container.safeAreaInsets.bottom - detentHeight - presentedFrameInContainer.minY
let slideAnimator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1)
slideAnimator.addAnimations {
container.transform = CGAffineTransform(translationX: 0, y: heightToSlide + 10)
}
slideAnimator.addCompletion { _ in
duckable.duckableViewControllerDidFinishAnimatingDuck()
transitionContext.completeTransition(true)
}
slideAnimator.startAnimation()
placeholder.view.transform = CGAffineTransform(translationX: 0, y: 10)
let bounceAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
placeholder.view.transform = .identity
container.transform = CGAffineTransform(translationX: 0, y: heightToSlide)
}
bounceAnimator.startAnimation(afterDelay: 0.3)
let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
presented.view.layer.opacity = 0
}
fadeAnimator.startAnimation(afterDelay: 0.3)
} else {
duckable.duckableViewControllerWillAnimateDuck(withDuration: 0.2, afterDelay: 0)
placeholder.view.transform = CGAffineTransform(translationX: 0, y: 10)
let bounceAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
placeholder.view.transform = .identity
container.transform = CGAffineTransform(translationX: 0, y: -10)
}
bounceAnimator.startAnimation(afterDelay: 0.2)
let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
presented.view.layer.opacity = 0
}
fadeAnimator.addCompletion { _ in
duckable.duckableViewControllerDidFinishAnimatingDuck()
transitionContext.completeTransition(true)
}
fadeAnimator.startAnimation(afterDelay: 0.2)
}
}
}

View File

@ -0,0 +1,217 @@
//
// DuckableContainerViewController.swift
// Duckable
//
// Created by Shadowfacts on 11/7/22.
//
import UIKit
let duckedCornerRadius: CGFloat = 10
let detentHeight: CGFloat = 44
@available(iOS 16.0, *)
public class DuckableContainerViewController: UIViewController, DuckableViewControllerDelegate {
public let child: UIViewController
private var bottomConstraint: NSLayoutConstraint!
private(set) var state = State.idle
public init(child: UIViewController) {
self.child = child
super.init(nibName: nil, bundle: nil)
swizzleSheetController()
}
required init?(coder: NSCoder) {
fatalError()
}
public override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
child.beginAppearanceTransition(true, animated: false)
addChild(child)
child.didMove(toParent: self)
child.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(child.view)
child.endAppearanceTransition()
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
NSLayoutConstraint.activate([
child.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
child.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
child.view.topAnchor.constraint(equalTo: view.topAnchor),
bottomConstraint,
])
}
func presentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
guard case .idle = state else {
if animated,
case .ducked(_, placeholder: let placeholder) = state {
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
let origConstant = placeholder.topConstraint.constant
UIView.animateKeyframes(withDuration: 0.4, delay: 0) {
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
placeholder.topConstraint.constant = origConstant - 20
self.view.layoutIfNeeded()
}
UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) {
placeholder.topConstraint.constant = origConstant
self.view.layoutIfNeeded()
}
}
}
return
}
state = .presentingDucked(viewController, isFirstPresentation: true)
doPresentDuckable(viewController, animated: animated, completion: completion)
}
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
viewController.duckableDelegate = self
let nav = UINavigationController(rootViewController: viewController)
nav.modalPresentationStyle = .custom
nav.transitioningDelegate = self
present(nav, animated: animated) {
self.bottomConstraint.isActive = false
self.bottomConstraint = self.child.view.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight - 4)
self.bottomConstraint.isActive = true
completion?()
}
}
public func duckableViewControllerWillDismiss(animated: Bool) {
state = .idle
bottomConstraint.isActive = false
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
bottomConstraint.isActive = true
child.view.layer.cornerRadius = 0
setOverrideTraitCollection(nil, forChild: child)
}
func createPlaceholderForDuckedViewController(_ viewController: DuckableViewController) -> DuckedPlaceholderViewController {
let placeholder = DuckedPlaceholderViewController(for: viewController, owner: self)
placeholder.view.translatesAutoresizingMaskIntoConstraints = false
placeholder.beginAppearanceTransition(true, animated: false)
self.addChild(placeholder)
placeholder.didMove(toParent: self)
self.view.addSubview(placeholder.view)
placeholder.endAppearanceTransition()
let placeholderTopConstraint = placeholder.view.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight)
placeholder.topConstraint = placeholderTopConstraint
NSLayoutConstraint.activate([
placeholder.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
placeholder.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
placeholder.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
placeholderTopConstraint
])
// otherwise the layout changes get lumped in with the system animation
UIView.performWithoutAnimation {
self.view.layoutIfNeeded()
}
return placeholder
}
func duckViewController() {
guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else {
return
}
let placeholder = createPlaceholderForDuckedViewController(viewController)
state = .ducked(viewController, placeholder: placeholder)
child.view.layer.cornerRadius = duckedCornerRadius
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
child.view.layer.masksToBounds = true
dismiss(animated: true)
}
@objc func unduckViewController() {
guard case .ducked(let viewController, placeholder: let placeholder) = state else {
return
}
state = .presentingDucked(viewController, isFirstPresentation: false)
doPresentDuckable(viewController, animated: true) {
placeholder.view.removeFromSuperview()
placeholder.willMove(toParent: nil)
placeholder.removeFromParent()
}
}
func sheetOffsetDidChange() {
if case .presentingDucked(let duckable, isFirstPresentation: _) = state {
duckable.duckableViewControllerMayAttemptToDuck()
}
}
enum State {
case idle
case presentingDucked(DuckableViewController, isFirstPresentation: Bool)
case ducked(DuckableViewController, placeholder: DuckedPlaceholderViewController)
}
}
@available(iOS 16.0, *)
extension DuckableContainerViewController: UIViewControllerTransitioningDelegate {
public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
let controller = UISheetPresentationController(presentedViewController: presented, presenting: presenting)
controller.delegate = self
controller.prefersGrabberVisible = true
controller.selectedDetentIdentifier = .large
controller.largestUndimmedDetentIdentifier = .bottom
controller.detents = [
.custom(identifier: .bottom, resolver: { context in
return detentHeight
}),
.large(),
]
return controller
}
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if case .ducked(_, placeholder: _) = state {
return DuckAnimationController(
owner: self,
needsShrinkAnimation: isDetentChangingDueToGrabberAction
)
} else {
return nil
}
}
}
@available(iOS 16.0, *)
extension DuckableContainerViewController: UISheetPresentationControllerDelegate {
public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {
let snapshot = child.view.snapshotView(afterScreenUpdates: false)!
snapshot.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(snapshot)
NSLayoutConstraint.activate([
snapshot.leadingAnchor.constraint(equalTo: child.view.leadingAnchor),
snapshot.trailingAnchor.constraint(equalTo: child.view.trailingAnchor),
snapshot.topAnchor.constraint(equalTo: child.view.topAnchor),
snapshot.bottomAnchor.constraint(equalTo: child.view.bottomAnchor),
])
setOverrideTraitCollection(UITraitCollection(userInterfaceLevel: .elevated), forChild: child)
transitionCoordinator!.animate { context in
snapshot.layer.opacity = 0
} completion: { _ in
snapshot.removeFromSuperview()
}
}
public func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) {
if sheetPresentationController.selectedDetentIdentifier == .bottom {
duckViewController()
}
}
}

View File

@ -0,0 +1,71 @@
//
// DuckedPlaceholderView.swift
// Duckable
//
// Created by Shadowfacts on 11/7/22.
//
import UIKit
@available(iOS 16.0, *)
class DuckedPlaceholderViewController: UIViewController {
private unowned let owner: DuckableContainerViewController
private let navBar = UINavigationBar()
var topConstraint: NSLayoutConstraint!
init(for duckableViewController: DuckableViewController, owner: DuckableContainerViewController) {
self.owner = owner
super.init(nibName: nil, bundle: nil)
let item = UINavigationItem()
item.title = duckableViewController.navigationItem.title
item.titleView = duckableViewController.navigationItem.titleView
navBar.setItems([item], animated: false)
}
required init?(coder: NSCoder) {
fatalError()
}
override func viewDidLoad() {
super.viewDidLoad()
setBackgroundColor()
view.layer.cornerRadius = duckedCornerRadius
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
view.layer.shadowColor = UIColor.black.cgColor
view.layer.shadowOpacity = 0.05
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(placeholderTapped)))
let appearance = UINavigationBarAppearance()
appearance.configureWithTransparentBackground()
navBar.standardAppearance = appearance
navBar.isUserInteractionEnabled = false
navBar.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(navBar)
NSLayoutConstraint.activate([
navBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
navBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
navBar.topAnchor.constraint(equalTo: view.topAnchor),
])
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
setBackgroundColor()
}
private func setBackgroundColor() {
// when just using .systemBackground and setting the override trait collection for the placeholder VC,
// the color doesn't change until after the dismiss animation occurs (but only when tapping the grabber to duck, not when swiping)
view.backgroundColor = .systemBackground.resolvedColor(with: UITraitCollection(traitsFrom: [traitCollection, UITraitCollection(userInterfaceLevel: .elevated)]))
}
@objc private func placeholderTapped() {
owner.unduckViewController()
}
}

View File

@ -0,0 +1,33 @@
//
// Swizzler.swift
// Duckable
//
// Created by Shadowfacts on 11/7/22.
//
import UIKit
import os.log
private var hasInitialized = false
var isDetentChangingDueToGrabberAction = false
@available(iOS 16.0, *)
func swizzleSheetController() {
guard !hasInitialized else {
return
}
hasInitialized = true
var originalIMP: IMP?
let imp = imp_implementationWithBlock({ (self: UISheetPresentationController, param: AnyObject) in
let original = unsafeBitCast(originalIMP!, to: (@convention(c) (UISheetPresentationController, AnyObject) -> Void).self)
isDetentChangingDueToGrabberAction = true
original(self, param)
isDetentChangingDueToGrabberAction = false
} as @convention(block) (UISheetPresentationController, AnyObject) -> Void)
let sel = [":", "PrimaryAction", "GrabberDidTrigger", "dropShadowView", "_"].reversed().joined()
originalIMP = class_replaceMethod(UISheetPresentationController.self, Selector(sel), imp, "v@:@")
if originalIMP == nil {
os_log(.fault, log: .default, "Unable to initialize Duckable grabber tap hook")
}
}

View File

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

View File

@ -0,0 +1,70 @@
//
// CreateListService.swift
// Tusker
//
// Created by Shadowfacts on 11/11/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
@MainActor
class CreateListService {
private let mastodonController: MastodonController
private let present: (UIViewController) -> Void
private let didCreateList: (@MainActor (List) -> Void)?
private var createAction: UIAlertAction?
init(mastodonController: MastodonController, present: @escaping (UIViewController) -> Void, didCreateList: (@MainActor (List) -> Void)?) {
self.mastodonController = mastodonController
self.present = present
self.didCreateList = didCreateList
}
func run() {
let alert = UIAlertController(title: NSLocalizedString("New List", comment: "new list alert title"), message: NSLocalizedString("Choose a title for your new list", comment: "new list alert message"), preferredStyle: .alert)
alert.addTextField { textField in
textField.addTarget(self, action: #selector(self.alertTextFieldValueChanged), for: .editingChanged)
}
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "new list alert cancel button"), style: .cancel, handler: nil))
createAction = UIAlertAction(title: NSLocalizedString("Create List", comment: "new list create button"), style: .default, handler: { (_) in
let textField = alert.textFields!.first!
let title = textField.text ?? ""
Task {
await self.createList(with: title)
}
})
createAction!.isEnabled = false
alert.addAction(createAction!)
present(alert)
}
@objc private func alertTextFieldValueChanged(_ textField: UITextField) {
createAction?.isEnabled = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
private func createList(with title: String) async {
do {
let request = Client.createList(title: title)
let (list, _) = try await mastodonController.run(request)
NotificationCenter.default.post(name: .listsChanged, object: nil)
self.didCreateList?(list)
} catch {
let alert = UIAlertController(title: "Error Creating List", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Retry", style: .default, handler: { _ in
Task {
await self.createList(with: title)
}
}))
present(alert)
}
}
}
extension Foundation.Notification.Name {
static let listsChanged = Notification.Name("listsChanged")
}

View File

@ -0,0 +1,65 @@
//
// DeleteListService.swift
// Tusker
//
// Created by Shadowfacts on 11/11/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
@MainActor
class DeleteListService {
private let list: List
private let mastodonController: MastodonController
private let present: (UIViewController) -> Void
init(list: List, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) {
self.list = list
self.mastodonController = mastodonController
self.present = present
}
@discardableResult
func run() async -> Bool {
if await presentConfirmationAlert() {
await deleteList()
return true
} else {
return false
}
}
private func presentConfirmationAlert() async -> Bool {
await withCheckedContinuation { continuation in
let titleFormat = NSLocalizedString("Are you sure you want to delete the '%@' list?", comment: "delete list alert title")
let title = String(format: titleFormat, list.title)
let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "delete list alert cancel button"), style: .cancel, handler: { (_) in
continuation.resume(returning: false)
}))
alert.addAction(UIAlertAction(title: NSLocalizedString("Delete List", comment: "delete list alert confirm button"), style: .destructive, handler: { (_) in
continuation.resume(returning: true)
}))
present(alert)
}
}
private func deleteList() async {
do {
let request = List.delete(list)
_ = try await mastodonController.run(request)
NotificationCenter.default.post(name: .listsChanged, object: nil)
} catch {
let alert = UIAlertController(title: "Error Deleting List", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Retry", style: .default, handler: { _ in
Task {
await self.deleteList()
}
}))
present(alert)
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,69 @@
//
// RenameListService.swift
// Tusker
//
// Created by Shadowfacts on 11/11/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
@MainActor
class RenameListService {
private let list: List
private let mastodonController: MastodonController
private let present: (UIViewController) -> Void
private var renameAction: UIAlertAction?
init(list: List, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) {
self.list = list
self.mastodonController = mastodonController
self.present = present
}
func run() {
let alert = UIAlertController(title: NSLocalizedString("Rename List", comment: "rename list alert title"), message: nil, preferredStyle: .alert)
alert.addTextField { (textField) in
textField.text = self.list.title
textField.addTarget(self, action: #selector(self.alertTextFieldValueChanged), for: .editingChanged)
}
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "rename list alert cancel button"), style: .cancel, handler: nil))
renameAction = UIAlertAction(title: NSLocalizedString("Rename", comment: "renaem list alert rename button"), style: .default, handler: { (_) in
let textField = alert.textFields!.first!
let title = textField.text ?? ""
Task {
await self.updateList(with: title)
}
})
alert.addAction(renameAction!)
present(alert)
}
@objc private func alertTextFieldValueChanged(_ textField: UITextField) {
renameAction?.isEnabled = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
private func updateList(with title: String) async {
do {
let req = List.update(list, title: title)
let (list, _) = try await mastodonController.run(req)
NotificationCenter.default.post(name: .listRenamed, object: list.id, userInfo: ["list": list])
} catch {
let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Retry", style: .default, handler: { _ in
Task {
await self.updateList(with: title)
}
}))
present(alert)
}
}
}
extension Foundation.Notification.Name {
static let listRenamed = Notification.Name("listRenamed")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,18 +13,14 @@ import AVKit
class AssetPreviewViewController: UIViewController {
let attachment: CompositionAttachmentData
let asset: PHAsset
init(attachment: CompositionAttachmentData) {
self.attachment = attachment
init(asset: PHAsset) {
self.asset = asset
super.init(nibName: nil, bundle: nil)
}
convenience init(asset: PHAsset) {
self.init(attachment: .asset(asset))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@ -34,27 +30,17 @@ class AssetPreviewViewController: UIViewController {
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 {
case .image:
if asset.mediaSubtypes.contains(.photoLive) {
showLivePhoto(asset)
} else {
showAssetImage(asset)
}
case .video:
showAssetVideo(asset)
default:
fatalError("asset mediaType must be image or video")
switch asset.mediaType {
case .image:
if asset.mediaSubtypes.contains(.photoLive) {
showLivePhoto(asset)
} else {
showAssetImage(asset)
}
case let .drawing(drawing):
let image = drawing.imageInLightMode(from: drawing.bounds)
showImage(image)
case .video:
showAssetVideo(asset)
default:
fatalError("asset mediaType must be image or video")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,35 +0,0 @@
//
// ComposeContainerView.swift
// Tusker
//
// Created by Shadowfacts on 8/24/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
import Combine
struct ComposeContainerView: View {
let mastodonController: MastodonController
@ObservedObject var uiState: ComposeUIState
init(
mastodonController: MastodonController,
uiState: ComposeUIState
) {
self.mastodonController = mastodonController
self.uiState = uiState
}
var body: some View {
ComposeView(draft: uiState.draft)
.environmentObject(mastodonController)
.environmentObject(uiState)
}
}
//struct ComposeContainerView_Previews: PreviewProvider {
// static var previews: some View {
// ComposeContainerView()
// }
//}

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,8 @@ import SwiftUI
struct ComposeReplyView: View {
let status: StatusMO
let stackPadding: CGFloat
let rowTopInset: CGFloat
let globalFrameOutsideList: CGRect
@State private var displayNameHeight: CGFloat?
@State private var contentHeight: CGFloat?
@ -46,7 +47,11 @@ struct ComposeReplyView: View {
})
ComposeReplyContentView(status: status) { newHeight in
contentHeight = newHeight
// otherwise, with long in-reply-to statuses, the main content text view position seems not to update
// and it ends up partially behind the header
DispatchQueue.main.async {
contentHeight = newHeight
}
}
.frame(height: contentHeight ?? 0)
}
@ -55,10 +60,12 @@ struct ComposeReplyView: View {
}
private func replyAvatarImage(geometry: GeometryProxy) -> some View {
let scrollOffset = -geometry.frame(in: .named(ComposeView.coordinateSpaceOutsideOfScrollView)).minY
// using a coordinate space declared outside of the List doesn't work, so we do the math ourselves
let globalFrame = geometry.frame(in: .global)
let scrollOffset = -(globalFrame.minY - globalFrameOutsideList.minY)
// add stackPadding so that the image is always at least stackPadding away from the top
var offset = scrollOffset + stackPadding
// add rowTopInset so that the image is always at least rowTopInset away from the top
var offset = scrollOffset + rowTopInset
// offset can never be less than 0 (i.e., above the top of the in-reply-to content)
offset = max(offset, 0)

View File

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

View File

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

View File

@ -0,0 +1,156 @@
//
// ComposeToolbar.swift
// Tusker
//
// Created by Shadowfacts on 11/12/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
struct ComposeToolbar: View {
static let height: CGFloat = 44
private static let visibilityOptions: [MenuPicker.Option] = Status.Visibility.allCases.map { vis in
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
}
@ObservedObject var draft: Draft
@EnvironmentObject private var uiState: ComposeUIState
@EnvironmentObject private var mastodonController: MastodonController
@ObservedObject private var preferences = Preferences.shared
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
@State private var minWidth: CGFloat?
@State private var realWidth: CGFloat?
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
Button("CW") {
draft.contentWarningEnabled.toggle()
}
.accessibilityLabel(draft.contentWarningEnabled ? "Remove content warning" : "Add content warning")
.padding(5)
.hoverEffect()
MenuPicker(selection: $draft.visibility, options: Self.visibilityOptions, buttonStyle: .iconOnly)
// // the button has a bunch of extra space by default, but combined with what we add it's too much
// .padding(.horizontal, -8)
if mastodonController.instanceFeatures.localOnlyPosts {
MenuPicker(selection: $draft.localOnly, options: [
.init(value: true, title: "Local-only", subtitle: "Only \(mastodonController.accountInfo!.instanceURL.host!)", image: UIImage(named: "link.broken")),
.init(value: false, title: "Federated", image: UIImage(systemName: "link"))
], buttonStyle: .iconOnly)
// .padding(.horizontal, -8)
}
if let currentInput = uiState.currentInput, currentInput.toolbarElements.contains(.emojiPicker) {
Button(action: self.emojiPickerButtonPressed) {
Label("Insert custom emoji", systemImage: "face.smiling")
}
.labelStyle(.iconOnly)
.font(.system(size: imageSize))
.padding(5)
.hoverEffect()
}
if let currentInput = uiState.currentInput,
currentInput.toolbarElements.contains(.formattingButtons),
preferences.statusContentType != .plain {
Spacer()
ForEach(StatusFormat.allCases, id: \.rawValue) { format in
Button(action: self.formatAction(format)) {
if let imageName = format.imageName {
Image(systemName: imageName)
.font(.system(size: imageSize))
} else if let (str, attrs) = format.title {
let container = try! AttributeContainer(attrs, including: \.uiKit)
Text(AttributedString(str, attributes: container))
}
}
.accessibilityLabel(format.accessibilityLabel)
.padding(5)
.hoverEffect()
}
}
Spacer()
Button(action: self.draftsButtonPressed) {
Text("Drafts")
}
.padding(5)
.hoverEffect()
}
.padding(.horizontal, 16)
.frame(minWidth: minWidth)
.background(GeometryReader { proxy in
Color.clear
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
realWidth = width
}
})
}
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
.frame(height: Self.height)
.frame(maxWidth: .infinity)
.background(.regularMaterial, ignoresSafeAreaEdges: .bottom)
.overlay(alignment: .top) {
Divider()
}
.background(GeometryReader { proxy in
Color.clear
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
minWidth = width
}
})
}
private func emojiPickerButtonPressed() {
guard uiState.autocompleteState == nil else {
return
}
uiState.shouldEmojiAutocompletionBeginExpanded = true
uiState.currentInput?.beginAutocompletingEmoji()
}
private func draftsButtonPressed() {
uiState.isShowingDraftsList = true
}
private func formatAction(_ format: StatusFormat) -> () -> Void {
{
uiState.currentInput?.applyFormat(format)
}
}
}
private struct ToolbarWidthPrefKey: PreferenceKey {
static var defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = nextValue()
}
}
private extension View {
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
if #available(iOS 16.0, *) {
self.scrollDisabled(disabled)
} else {
self
}
}
}
struct ComposeToolbar_Previews: PreviewProvider {
static var previews: some View {
ComposeToolbar(draft: Draft(accountID: ""))
}
}

View File

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

View File

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

View File

@ -0,0 +1,127 @@
//
// DraftsView.swift
// Tusker
//
// Created by Shadowfacts on 11/9/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import SwiftUI
struct DraftsView: View {
let currentDraft: Draft
// don't pass this in via the environment b/c it crashes on macOS (at least, in Designed for iPad mode) since the environment doesn't get propagated through the modal popup window or something
let mastodonController: MastodonController
@EnvironmentObject var uiState: ComposeUIState
@StateObject private var draftsManager = DraftsManager.shared
@State private var draftForDifferentReply: Draft?
private var visibleDrafts: [Draft] {
draftsManager.sorted.filter {
$0.accountID == mastodonController.accountInfo!.id && $0.id != currentDraft.id
}
}
var body: some View {
NavigationView {
List {
ForEach(visibleDrafts) { draft in
Button {
maybeSelectDraft(draft)
} label: {
DraftView(draft: draft)
}
.contextMenu {
Button(role: .destructive) {
draftsManager.remove(draft)
} label: {
Label("Delete Draft", systemImage: "trash")
}
}
.onDrag {
let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: mastodonController.accountInfo!.id)
activity.displaysAuxiliaryScene = true
return NSItemProvider(object: activity)
}
}
.onDelete { indices in
indices
.map { visibleDrafts[$0] }
.forEach { draftsManager.remove($0) }
}
}
.listStyle(.plain)
.navigationTitle(Text("Drafts"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
uiState.isShowingDraftsList = false
}
}
}
}
.alertWithData("Different Reply", data: $draftForDifferentReply) { draft in
Button("Cancel", role: .cancel) {
draftForDifferentReply = nil
}
Button("Restore Draft") {
uiState.delegate?.selectDraft(draft)
}
} message: { draft in
Text("The selected draft is a reply to a different post, do you wish to use it?")
}
}
private func maybeSelectDraft(_ draft: Draft) {
if draft.inReplyToID != currentDraft.inReplyToID,
currentDraft.hasContent {
draftForDifferentReply = draft
} else {
uiState.delegate?.selectDraft(draft)
}
}
}
struct DraftView: View {
@ObservedObject private var draft: Draft
init(draft: Draft) {
self._draft = ObservedObject(wrappedValue: draft)
}
var body: some View {
HStack {
VStack(alignment: .leading) {
if draft.contentWarningEnabled {
Text(draft.contentWarning)
.font(.body.bold())
.foregroundColor(.secondary)
}
Text(draft.text)
.font(.body)
HStack(spacing: 8) {
ForEach(draft.attachments) { attachment in
ComposeAttachmentImage(attachment: attachment, fullSize: false)
.frame(width: 50, height: 50)
.cornerRadius(5)
}
}
}
Spacer()
Text(draft.lastModified.timeAgoString())
.font(.body)
.foregroundColor(.secondary)
}
}
}
//struct DraftsView_Previews: PreviewProvider {
// static var previews: some View {
// DraftsView(currentDraft: Draft(accountID: ""))
// }
//}

View File

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

View File

@ -1,142 +0,0 @@
//
// DraftsTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 10/22/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
protocol DraftsTableViewControllerDelegate: AnyObject {
func draftSelectionCanceled()
func shouldSelectDraft(_ draft: Draft, completion: @escaping (Bool) -> Void)
func draftSelected(_ draft: Draft)
func draftSelectionCompleted()
}
class DraftsTableViewController: UITableViewController {
let account: LocalData.UserAccountInfo
let excludedDraft: Draft?
weak var delegate: DraftsTableViewControllerDelegate?
var drafts = [Draft]()
init(account: LocalData.UserAccountInfo, exclude: Draft? = nil) {
self.account = account
self.excludedDraft = exclude
super.init(nibName: "DraftsTableViewController", bundle: nil)
title = "Drafts"
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelPressed))
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 140
tableView.register(UINib(nibName: "DraftTableViewCell", bundle: nil), forCellReuseIdentifier: "draftCell")
tableView.dragDelegate = self
drafts = DraftsManager.shared.sorted.filter { (draft) in
draft.accountID == account.id && draft != excludedDraft
}
}
func draft(for indexPath: IndexPath) -> Draft {
return drafts[indexPath.row]
}
// MARK: - Table View Data Source
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return drafts.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "draftCell", for: indexPath) as? DraftTableViewCell else { fatalError() }
cell.updateUI(for: draft(for: indexPath))
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let draft = self.draft(for: indexPath)
func select() {
delegate?.draftSelected(draft)
dismiss(animated: true) {
self.delegate?.draftSelectionCompleted()
}
}
if let delegate = delegate {
delegate.shouldSelectDraft(draft) { (shouldSelect) in
if shouldSelect {
select()
} else {
tableView.selectRow(at: nil, animated: true, scrollPosition: .none)
}
}
} else {
select()
}
}
override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
return .delete
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
guard editingStyle == .delete else { return }
DraftsManager.shared.remove(draft(for: indexPath))
drafts.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .automatic)
}
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(actionProvider: { _ in
return UIMenu(children: [
UIAction(title: "Delete Draft", image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [unowned self] _ in
DraftsManager.shared.remove(self.draft(for: indexPath))
drafts.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .automatic)
})
])
})
}
// MARK: - Interaction
@objc func cancelPressed() {
delegate?.draftSelectionCanceled()
dismiss(animated: true)
}
}
extension DraftsTableViewController: UITableViewDragDelegate {
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
let draft = self.draft(for: indexPath)
let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: account.id)
activity.displaysAuxiliaryScene = true
let provider = NSItemProvider(object: activity)
return [UIDragItem(itemProvider: provider)]
}
}

View File

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14810.11" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14766.13"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="DraftsTableViewController" customModule="Tusker" customModuleProvider="target">
<connections>
<outlet property="view" destination="O5v-ea-iTS" id="sft-3K-LZf"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableView clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="O5v-ea-iTS">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<point key="canvasLocation" x="-302" y="87"/>
</tableView>
</objects>
</document>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,178 @@
//
// EditListSearchFollowingViewController.swift
// Tusker
//
// Created by Shadowfacts on 11/11/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class EditListSearchFollowingViewController: EnhancedTableViewController {
private let mastodonController: MastodonController
private let didSelectAccount: (String) -> Void
private var dataSource: UITableViewDiffableDataSource<Section, String>!
private var query: String?
private var accountIDs: [String] = []
private var nextRange: RequestRange?
init(mastodonController: MastodonController, didSelectAccount: @escaping (String) -> Void) {
self.mastodonController = mastodonController
self.didSelectAccount = didSelectAccount
super.init(style: .grouped)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: "accountCell")
dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, itemIdentifier in
let cell = tableView.dequeueReusableCell(withIdentifier: "accountCell", for: indexPath) as! AccountTableViewCell
cell.delegate = self
cell.updateUI(accountID: itemIdentifier)
return cell
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if dataSource.snapshot().numberOfItems == 0 {
Task {
await load()
}
}
}
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
print("will display: \(indexPath)")
if indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - 1 {
Task {
await load()
}
}
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let id = dataSource.itemIdentifier(for: indexPath) else {
return
}
didSelectAccount(id)
}
private func load() async {
do {
let ownAccount = try await mastodonController.getOwnAccount()
let req = Account.getFollowing(ownAccount.id, range: nextRange ?? .default)
let (following, pagination) = try await mastodonController.run(req)
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(accounts: following) {
continuation.resume()
}
}
accountIDs.append(contentsOf: following.lazy.map(\.id))
nextRange = pagination?.older
updateDataSource(appending: following.map(\.id))
} catch {
let config = ToastConfiguration(from: error, with: "Error Loading Following", in: self) { toast in
toast.dismissToast(animated: true)
await self.load()
}
self.showToast(configuration: config, animated: true)
}
}
private func updateDataSourceForQueryChanged() {
guard let query, !query.isEmpty else {
let snapshot = NSDiffableDataSourceSnapshot<Section, String>()
dataSource.apply(snapshot, animatingDifferences: true)
return
}
let ids = filterAccounts(ids: accountIDs, with: query)
var snapshot = dataSource.snapshot()
if snapshot.indexOfSection(.accounts) == nil {
snapshot.appendSections([.accounts])
} else {
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .accounts))
}
snapshot.appendItems(ids)
dataSource.apply(snapshot, animatingDifferences: true)
// if there aren't any results for the current query, try to load more
if ids.isEmpty {
Task {
await load()
}
}
}
private func updateDataSource(appending ids: [String]) {
guard let query, !query.isEmpty else {
let snapshot = NSDiffableDataSourceSnapshot<Section, String>()
dataSource.apply(snapshot, animatingDifferences: true)
return
}
let ids = filterAccounts(ids: ids, with: query)
var snapshot = dataSource.snapshot()
if snapshot.indexOfSection(.accounts) == nil {
snapshot.appendSections([.accounts])
}
let existing = snapshot.itemIdentifiers(inSection: .accounts)
snapshot.appendItems(ids.filter { !existing.contains($0) })
dataSource.apply(snapshot, animatingDifferences: true)
// if there aren't any results for the current query, try to load more
if ids.isEmpty {
Task {
await load()
}
}
}
private func filterAccounts(ids: [String], with query: String) -> [String] {
let req = AccountMO.fetchRequest()
req.predicate = NSPredicate(format: "id in %@", ids)
let accounts = try! mastodonController.persistentContainer.viewContext.fetch(req)
return accounts
.map { (account) -> (AccountMO, Bool) in
let displayNameMatch = FuzzyMatcher.match(pattern: query, str: account.displayNameWithoutCustomEmoji)
let usernameMatch = FuzzyMatcher.match(pattern: query, str: account.acct)
return (account, displayNameMatch.matched || usernameMatch.matched)
}
.filter(\.1)
.map(\.0.id)
}
func updateQuery(_ query: String) {
self.query = query
updateDataSourceForQueryChanged()
}
}
extension EditListSearchFollowingViewController {
enum Section {
case accounts
}
}
extension EditListSearchFollowingViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension EditListSearchFollowingViewController: MenuActionProvider {
}

View File

@ -0,0 +1,115 @@
//
// EditListSearchResultsContainerViewController.swift
// Tusker
//
// Created by Shadowfacts on 11/11/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Combine
class EditListSearchResultsContainerViewController: UIViewController {
private let mastodonController: MastodonController
private let didSelectAccount: (String) -> Void
private let searchResultsController: SearchResultsViewController
private let searchFollowingController: EditListSearchFollowingViewController
var mode = Mode.search {
willSet {
currentViewController.removeViewAndController()
}
didSet {
embedChild(currentViewController)
}
}
var currentViewController: UIViewController {
switch mode {
case .search:
return searchResultsController
case .following:
return searchFollowingController
}
}
private var currentQuery: String?
private var searchSubject = PassthroughSubject<String?, Never>()
private var cancellables = Set<AnyCancellable>()
init(mastodonController: MastodonController, didSelectAccount: @escaping (String) -> Void) {
self.mastodonController = mastodonController
self.didSelectAccount = didSelectAccount
self.searchResultsController = SearchResultsViewController(mastodonController: mastodonController, resultTypes: [.accounts])
self.searchFollowingController = EditListSearchFollowingViewController(mastodonController: mastodonController, didSelectAccount: didSelectAccount)
super.init(nibName: nil, bundle: nil)
self.searchResultsController.delegate = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
embedChild(currentViewController)
searchSubject
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.sink { [unowned self] in self.performSearch(query: $0) }
.store(in: &cancellables)
}
func performSearch(query: String?) {
guard var query = query?.trimmingCharacters(in: .whitespacesAndNewlines) else {
return
}
if query.starts(with: "@") {
query = String(query.dropFirst())
}
guard query != self.currentQuery else {
return
}
self.currentQuery = query
switch mode {
case .search:
searchResultsController.performSearch(query: query)
case .following:
searchFollowingController.updateQuery(query)
}
}
enum Mode: Equatable {
case search, following
}
}
extension EditListSearchResultsContainerViewController: UISearchResultsUpdating {
func updateSearchResults(for searchController: UISearchController) {
searchSubject.send(searchController.searchBar.text)
}
}
extension EditListSearchResultsContainerViewController: UISearchBarDelegate {
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
performSearch(query: searchBar.text)
}
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
mode = selectedScope == 0 ? .search : .following
performSearch(query: searchBar.text)
}
}
extension EditListSearchResultsContainerViewController: SearchResultsViewControllerDelegate {
func selectedSearchResult(account accountID: String) {
didSelectAccount(accountID)
}
}

View File

@ -11,7 +11,7 @@ import Pachyderm
class ListTimelineViewController: TimelineViewController {
let list: List
private(set) var list: List
var presentEditOnAppear = false
@ -20,7 +20,9 @@ class ListTimelineViewController: TimelineViewController {
super.init(for: .list(id: list.id), mastodonController: mastodonController)
title = list.title
listChanged()
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: list.id)
}
required init?(coder aDecoder: NSCoder) {
@ -41,6 +43,16 @@ class ListTimelineViewController: TimelineViewController {
}
}
private func listChanged() {
title = list.title
}
@objc private func listRenamed(_ notification: Foundation.Notification) {
let list = notification.userInfo!["list"] as! List
self.list = list
self.listChanged()
}
func presentEdit(animated: Bool) {
let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController)
editListAccountsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed))

View File

@ -0,0 +1,37 @@
//
// Duckable+Root.swift
// Tusker
//
// Created by Shadowfacts on 11/7/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Duckable
@available(iOS 16.0, *)
extension DuckableContainerViewController: TuskerRootViewController {
func presentCompose() {
(child as? TuskerRootViewController)?.presentCompose()
}
func select(tab: MainTabBarViewController.Tab) {
(child as? TuskerRootViewController)?.select(tab: tab)
}
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
return (child as? TuskerRootViewController)?.getTabController(tab: tab)
}
func performSearch(query: String) {
(child as? TuskerRootViewController)?.performSearch(query: query)
}
func presentPreferences(completion: (() -> Void)?) {
(child as? TuskerRootViewController)?.presentPreferences(completion: completion)
}
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
(child as? TuskerRootViewController)?.handleStatusBarTapped(xPosition: xPosition) ?? .continue
}
}

View File

@ -99,6 +99,8 @@ class MainSidebarViewController: UIViewController {
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(reloadLists), name: .listsChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
onViewDidLoad?()
@ -201,7 +203,7 @@ class MainSidebarViewController: UIViewController {
}
}
private func reloadLists() {
@objc private func reloadLists() {
let request = Client.getLists()
mastodonController.run(request) { [weak self] (response) in
guard let self = self, case let .success(lists, _) = response else { return }
@ -223,6 +225,23 @@ class MainSidebarViewController: UIViewController {
}
}
@objc private func listRenamed(_ notification: Foundation.Notification) {
let list = notification.userInfo!["list"] as! List
var snapshot = dataSource.snapshot()
let existing = snapshot.itemIdentifiers(inSection: .lists).first(where: {
if case .list(let existingList) = $0, existingList.id == list.id {
return true
} else {
return false
}
})
if let existing {
snapshot.insertItems([.list(list)], afterItem: existing)
snapshot.deleteItems([existing])
dataSource.apply(snapshot)
}
}
@MainActor
private func fetchSavedHashtags() -> [SavedHashtag] {
let req = SavedHashtag.fetchRequest()
@ -297,28 +316,12 @@ class MainSidebarViewController: UIViewController {
}
}
// todo: deduplicate with ExploreViewController
private func showAddList() {
let alert = UIAlertController(title: "New List", message: "Choose a title for your new list", preferredStyle: .alert)
alert.addTextField(configurationHandler: nil)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
alert.addAction(UIAlertAction(title: "Create List", style: .default, handler: { (_) in
guard let title = alert.textFields?.first?.text else {
fatalError()
}
let request = Client.createList(title: title)
self.mastodonController.run(request) { (response) in
guard case let .success(list, _) = response else { fatalError() }
self.reloadLists()
DispatchQueue.main.async {
self.sidebarDelegate?.sidebar(self, didSelectItem: .list(list))
}
}
}))
present(alert, animated: true)
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true
) }) { list in
self.sidebarDelegate?.sidebar(self, didSelectItem: .list(list))
}
service.run()
}
// todo: deduplicate with ExploreViewController
@ -551,11 +554,22 @@ extension MainSidebarViewController: UICollectionViewDelegate {
}
activity.displaysAuxiliaryScene = true
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) in
return UIMenu(children: [
var actions: [UIAction] = [
UIWindowScene.ActivationAction({ action in
return UIWindowScene.ActivationConfiguration(userActivity: activity)
}),
])
]
if case .list(let list) = item {
actions.append(UIAction(title: "Delete List", image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [unowned self] _ in
Task {
let service = DeleteListService(list: list, mastodonController: self.mastodonController, present: { self.present($0, animated: true) })
await service.run()
}
}))
}
return UIMenu(children: actions)
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,145 @@
//
// MuteAccountView.swift
// Tusker
//
// Created by Shadowfacts on 11/11/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
struct MuteAccountView: View {
private static let durationOptions: [MenuPicker<TimeInterval>.Option] = {
let f = DateComponentsFormatter()
f.maximumUnitCount = 1
f.unitsStyle = .full
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
let durations: [TimeInterval] = [
30 * 60,
60 * 60,
6 * 60 * 60,
24 * 60 * 60,
3 * 24 * 60 * 60,
7 * 60 * 60 * 60,
]
return [
.init(value: 0, title: "Forever")
] + durations.map { .init(value: $0, title: f.string(from: $0)!) }
}()
let account: AccountMO
let mastodonController: MastodonController
@Environment(\.dismiss) private var dismiss: DismissAction
@ObservedObject private var preferences = Preferences.shared
@State private var muteNotifications = true
@State private var duration: TimeInterval = 0
@State private var isMuting = false
@State private var error: Error?
var body: some View {
NavigationView {
navigationViewContent
}
}
private var navigationViewContent: some View {
Form {
Section {
HStack {
ComposeAvatarImageView(url: account.avatar)
.frame(width: 50, height: 50)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
VStack(alignment: .leading) {
AccountDisplayNameLabel(account: account, textStyle: .headline, emojiSize: 17)
Text("@\(account.acct)")
.fontWeight(.light)
.foregroundColor(.secondary)
}
}
.frame(height: 50)
.listRowBackground(EmptyView())
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
.accessibilityHidden(true)
Section {
Toggle(isOn: $muteNotifications) {
Text("Hide notifications from this person")
}
} footer: {
if muteNotifications {
Text("This user's posts and notifications will be hidden.")
} else {
Text("This user's posts will be hidden from your timeline. You can still receive notifications from them.")
}
}
Section {
Picker(selection: $duration) {
ForEach(MuteAccountView.durationOptions, id: \.value) { option in
Text(option.title).tag(option.value)
}
} label: {
Text("Duration")
}
} footer: {
if duration != 0 {
Text("The mute will automatically be removed after the selected time.")
}
}
Button(action: self.mute) {
if isMuting {
HStack {
Text("Muting User")
Spacer()
ProgressView()
.progressViewStyle(.circular)
}
} else {
Text("Mute User")
}
}
.disabled(isMuting)
}
.alertWithData("Erorr Muting", data: $error, actions: { error in
Button("Ok") {}
}, message: { error in
Text(error.localizedDescription)
})
.navigationTitle("Mute \(account.displayOrUserName)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
self.dismiss()
}
}
}
}
private func mute() {
isMuting = true
let req = Account.mute(account.id, notifications: muteNotifications)
Task {
do {
let (relationship, _) = try await mastodonController.run(req)
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
self.dismiss()
} catch {
self.error = error
isMuting = false
}
}
}
}
//struct MuteAccountView_Previews: PreviewProvider {
// static var previews: some View {
// MuteAccountView()
// }
//}

View File

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

View File

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

View File

@ -10,7 +10,7 @@ import UIKit
import Pachyderm
import Combine
class ProfileViewController: UIPageViewController {
class ProfileViewController: UIViewController {
weak var mastodonController: MastodonController!
@ -42,7 +42,7 @@ class ProfileViewController: UIPageViewController {
self.accountID = accountID
self.mastodonController = mastodonController
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
super.init(nibName: nil, bundle: nil)
self.pageControllers = [
.init(accountID: accountID, kind: .statuses, owner: self),
@ -146,26 +146,32 @@ class ProfileViewController: UIPageViewController {
state = .animating
let direction: UIPageViewController.NavigationDirection
if currentIndex == nil || index - currentIndex > 0 {
direction = .forward
} else {
direction = .reverse
}
guard let old = viewControllers?.first as? ProfileStatusesViewController else {
// if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary
pageControllers[index].initialHeaderMode = .createView
setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in
self.state = .idle
completion?(finished)
}
currentIndex = index
return
}
let new = pageControllers[index]
currentIndex = index
guard let currentIndex else {
assert(!animated)
// if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary
new.initialHeaderMode = .createView
new.view.translatesAutoresizingMaskIntoConstraints = false
embedChild(new)
self.currentIndex = index
state = .idle
completion?(true)
return
}
let direction: CGFloat
if index - currentIndex > 0 {
direction = 1 // forward
} else {
direction = -1 // reverse
}
let old = pageControllers[currentIndex]
new.loadViewIfNeeded()
self.currentIndex = index
// TODO: old.headerCell could be nil if scrolled down and key command used
let oldHeaderCell = old.headerCell!
@ -173,8 +179,8 @@ class ProfileViewController: UIPageViewController {
// old header cell must have the header view
let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)!
if new.isViewLoaded {
_ = new.headerCell!.addConstraint(height: oldHeaderCell.bounds.height)
if let newHeaderCell = new.headerCell {
_ = newHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)
} else {
new.initialHeaderMode = .placeholder(height: oldHeaderCell.bounds.height)
}
@ -195,60 +201,66 @@ class ProfileViewController: UIPageViewController {
// hide scroll indicators during the transition because otherwise the show through the
// profile header, even though it has an opaque background
old.collectionView.showsVerticalScrollIndicator = false
if new.isViewLoaded {
new.collectionView.showsVerticalScrollIndicator = false
new.collectionView.showsVerticalScrollIndicator = false
let origOldContentOffset = old.collectionView.contentOffset
// we can't just change the content offset during the animation, otherwise the new collection view doesn't size the cells at the top
// and new's offset doesn't physically match old's, even though they're numerically the same
let needsMatchContentOffsetWithTransform = new.state != .loaded
let yTranslationToMatchOldContentOffset: CGFloat
if needsMatchContentOffsetWithTransform {
yTranslationToMatchOldContentOffset = -origOldContentOffset.y - view.safeAreaInsets.top
} else {
new.collectionView.contentOffset = origOldContentOffset
yTranslationToMatchOldContentOffset = 0
}
// if the new view isn't loaded or it isn't tall enough to match content offsets, animate scrolling old back to top to match new
if animated,
!new.isViewLoaded || new.collectionView.contentSize.height - new.collectionView.bounds.height < old.collectionView.contentOffset.y {
// We need to display a snapshot over the old view because setting the content offset to the top w/o animating
// results in the collection view immediately removing cells that will be offscreen.
// And we can't just call setContentOffset(_:animated:) because its animation curve does not match ours/the page views
// So, we capture a snapshot before the content offset is changed, so those cells can be shown during the animation,
// rather than a gap appearing during it.
let snapshot = old.collectionView.snapshotView(afterScreenUpdates: true)!
let origOldContentOffset = old.collectionView.contentOffset
old.collectionView.contentOffset = CGPoint(x: 0, y: view.safeAreaInsets.top)
snapshot.frame = old.collectionView.bounds
snapshot.frame.origin.y = 0
snapshot.layer.zPosition = 99
view.addSubview(snapshot)
// empirically, 0.3s seems to match the UIPageViewController animation
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) {
// animate the snapshot offscreen in the same direction as the old view
snapshot.frame.origin.x = direction == .forward ? -self.view.bounds.width : self.view.bounds.width
// animate the snapshot to be "scrolled" to top
snapshot.frame.origin.y = self.view.safeAreaInsets.top + origOldContentOffset.y
// if scrolling because the new collection view's content isn't tall enough, make sure to scroll it to top as well
if new.isViewLoaded {
new.collectionView.contentOffset = CGPoint(x: 0, y: -self.view.safeAreaInsets.top)
}
headerView.transform = CGAffineTransform(translationX: 0, y: -headerTopOffset)
} completion: { _ in
snapshot.removeFromSuperview()
if animated {
// if the new view isn't tall enough to match content offsets
if new.collectionView.contentSize.height - new.collectionView.bounds.height < old.collectionView.contentOffset.y {
let additionalHeightNeededToMatchContentOffset = old.collectionView.contentOffset.y + old.collectionView.bounds.height - new.collectionView.contentSize.height
new.collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: additionalHeightNeededToMatchContentOffset, right: 0)
}
} else if new.isViewLoaded {
new.collectionView.contentOffset = old.collectionView.contentOffset
}
setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in
// reenable scroll indicators after the switching animation is done
old.collectionView.showsVerticalScrollIndicator = true
new.collectionView.showsVerticalScrollIndicator = true
new.view.translatesAutoresizingMaskIntoConstraints = false
embedChild(new)
new.view.transform = CGAffineTransform(translationX: direction * view.bounds.width, y: yTranslationToMatchOldContentOffset)
headerView.isUserInteractionEnabled = true
let animator = UIViewPropertyAnimator(duration: 0.5, timingParameters: UISpringTimingParameters(dampingRatio: 1, initialVelocity: .zero))
animator.addAnimations {
new.view.transform = CGAffineTransform(translationX: 0, y: yTranslationToMatchOldContentOffset)
old.view.transform = CGAffineTransform(translationX: -direction * self.view.bounds.width, y: 0)
}
animator.addCompletion { _ in
old.removeViewAndController()
old.collectionView.transform = .identity
headerView.transform = .identity
headerView.layer.zPosition = 0
// move the header view into the new page controller's cell
// new's headerCell should always be non-nil, because the account must be loaded (in order to have triggered this switch), and so new should add the cell immediately on load
new.headerCell!.addHeader(headerView)
new.collectionView.transform = .identity
new.collectionView.contentOffset = origOldContentOffset
self.state = .idle
completion?(finished)
// reenable scroll indicators after the switching animation is done
old.collectionView.showsVerticalScrollIndicator = true
new.collectionView.showsVerticalScrollIndicator = true
headerView.isUserInteractionEnabled = true
headerView.transform = .identity
headerView.layer.zPosition = 0
// move the header view into the new page controller's cell
if let newHeaderCell = new.headerCell {
newHeaderCell.addHeader(headerView)
} else {
new.initialHeaderMode = .useExistingView(headerView)
}
self.state = .idle
completion?(true)
}
animator.startAnimation()
} else {
old.removeViewAndController()
new.view.translatesAutoresizingMaskIntoConstraints = false
embedChild(new)
completion?(true)
}
}

View File

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

View File

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

View File

@ -10,6 +10,7 @@ import UIKit
import SafariServices
import Pachyderm
import WebURLFoundationExtras
import SwiftUI
protocol MenuActionProvider: AnyObject {
var navigationDelegate: TuskerNavigationDelegate? { get }
@ -42,46 +43,6 @@ extension MenuActionProvider {
guard let mastodonController = mastodonController,
let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
guard let loggedInAccountID = mastodonController.accountInfo?.id else {
// user is logged out
return [
openInSafariAction(url: account.url),
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView)
})
]
}
let actionsSection: [UIMenuElement] = [
createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { [weak self] (_) in
guard let self = self else { return }
let draft = self.mastodonController!.createDraft(mentioningAcct: account.acct)
draft.visibility = .direct
self.navigationDelegate?.compose(editing: draft)
}),
UIDeferredMenuElement.uncached({ @MainActor [unowned self] elementHandler in
let relationship = Task {
await fetchRelationship(accountID: accountID, mastodonController: mastodonController)
}
// workaround for #198, may result in showing outdated relationship, so only do so where necessary
if ProcessInfo.processInfo.isiOSAppOnMac,
let mo = mastodonController.persistentContainer.relationship(forAccount: accountID),
let action = self.followAction(for: mo, mastodonController: mastodonController) {
elementHandler([action])
} else {
Task { @MainActor in
if let relationship = await relationship.value,
let action = self.followAction(for: relationship, mastodonController: mastodonController) {
elementHandler([action])
} else {
elementHandler([])
}
}
}
})
]
var shareSection = [
openInSafariAction(url: account.url),
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
@ -90,11 +51,33 @@ extension MenuActionProvider {
})
]
guard let loggedInAccountID = mastodonController.accountInfo?.id else {
// user is logged out
return shareSection
}
var actionsSection: [UIMenuElement] = [
createAction(identifier: "sendmessage", title: "Send Message", systemImageName: "envelope", handler: { [weak self] (_) in
guard let self = self else { return }
let draft = self.mastodonController!.createDraft(mentioningAcct: account.acct)
draft.visibility = .direct
self.navigationDelegate?.compose(editing: draft)
})
]
var suppressSection: [UIMenuElement] = []
if accountID != loggedInAccountID {
actionsSection.append(relationshipAction(accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.followAction(for: $0, mastodonController: $1) }))
suppressSection.append(relationshipAction(accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.blockAction(for: $0, mastodonController: $1) }))
suppressSection.append(relationshipAction(accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.muteAction(for: $0, mastodonController: $1) }))
}
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showProfileActivity(id: accountID, accountID: loggedInAccountID))
return [
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: actionsSection),
UIMenu(options: .displayInline, children: shareSection),
UIMenu(options: .displayInline, children: actionsSection),
UIMenu(options: .displayInline, children: suppressSection),
]
}
@ -166,12 +149,7 @@ extension MenuActionProvider {
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
case .failure(let error):
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)
}
}
self.handleError(error, title: "Error \(bookmarked ? "Unb" : "B")ookmarking")
}
}
}),
@ -227,12 +205,7 @@ extension MenuActionProvider {
case .success(let status, _):
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
case .failure(let error):
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)
}
}
self.handleError(error, title: "Error \(muted ? "Unm" : "M")uting")
}
}
}))
@ -251,12 +224,7 @@ extension MenuActionProvider {
case .success(let status, _):
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
case .failure(let error):
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)
}
}
self.handleError(error, title: "Error \(pinned ? "Unp" :"P")inning")
}
})
}))
@ -276,12 +244,7 @@ extension MenuActionProvider {
mastodonController.persistentContainer.addOrUpdate(status: status, context: mastodonController.persistentContainer.viewContext)
}
case .failure(let error):
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)
}
}
self?.handleError(error, title: "Error Refreshing Poll")
}
})
}), at: 0)
@ -367,25 +330,46 @@ extension MenuActionProvider {
})
}
@MainActor
private func followAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement? {
guard let ownAccount = mastodonController.account,
relationship.accountID != ownAccount.id else {
return nil
private func handleError(_ error: Client.Error, title: String) {
if let toastable = self.toastableViewController {
let config = ToastConfiguration(from: error, with: title, in: toastable, retryAction: nil)
DispatchQueue.main.async {
toastable.showToast(configuration: config, animated: true)
}
}
}
private func relationshipAction(accountID: String, mastodonController: MastodonController, builder: @escaping @MainActor (RelationshipMO, MastodonController) -> UIMenuElement) -> UIDeferredMenuElement {
return UIDeferredMenuElement.uncached({ @MainActor elementHandler in
let relationship = Task {
await fetchRelationship(accountID: accountID, mastodonController: mastodonController)
}
// workaround for #198, may result in showing outdated relationship, so only do so where necessary
if ProcessInfo.processInfo.isiOSAppOnMac,
let mo = mastodonController.persistentContainer.relationship(forAccount: accountID) {
elementHandler([builder(mo, mastodonController)])
} else {
Task { @MainActor in
if let relationship = await relationship.value {
elementHandler([builder(relationship, mastodonController)])
} else {
elementHandler([])
}
}
}
})
}
@MainActor
private func followAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement {
let accountID = relationship.accountID
let following = relationship.following
return createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus") { _ in
return createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus") { [weak self] _ in
let request = (following ? Account.unfollow : Account.follow)(accountID)
mastodonController.run(request) { response in
switch response {
case .failure(let error):
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)
}
}
self?.handleError(error, title: "Error \(following ? "Unf" : "F")ollowing")
case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
}
@ -393,6 +377,71 @@ extension MenuActionProvider {
}
}
@MainActor
private func blockAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement {
let accountID = relationship.accountID
let displayName = relationship.account!.displayOrUserName
let host = relationship.account!.url.host!
let handler = { (block: Bool) in
return { [weak self] (_: UIAction) in
let req = block ? Account.block(accountID) : Account.unblock(accountID)
_ = mastodonController.run(req) { response in
switch response {
case .failure(let error):
self?.handleError(error, title: "Error \(block ? "B" : "Unb")locking")
case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
}
}
}
}
let domainHandler = { (block: Bool) in
return { [weak self] (_: UIAction) in
let req = block ? Client.block(domain: host) : Client.unblock(domain: host)
mastodonController.run(req) { response in
if case .failure(let error) = response {
self?.handleError(error, title: "Error \(block ? "B" : "Unb")locking")
}
}
}
}
if relationship.domainBlocking {
return createAction(identifier: "block", title: "Unblock \(host)", systemImageName: "circle.slash", handler: domainHandler(false))
} else if relationship.blocking {
return createAction(identifier: "block", title: "Unblock \(displayName)", systemImageName: "circle.slash", handler: handler(false))
} else {
let image = UIImage(systemName: "circle.slash")
return UIMenu(title: "Block", image: image, children: [
UIAction(title: "Cancel", handler: { _ in }),
UIAction(title: "Block \(displayName)", image: image, attributes: .destructive, handler: handler(true)),
UIAction(title: "Block \(host)", image: image, attributes: .destructive, handler: domainHandler(true))
])
}
}
@MainActor
private func muteAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement {
if relationship.muting || relationship.mutingNotifications {
return UIAction(title: "Unmute", image: UIImage(systemName: "speaker")) { [weak self] _ in
let req = Account.unmute(relationship.accountID)
mastodonController.run(req) { response in
switch response {
case .failure(let error):
self?.handleError(error, title: "Error Unmuting")
case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
}
}
}
} else {
return UIAction(title: "Mute", image: UIImage(systemName: "speaker.slash")) { [weak self] _ in
let view = MuteAccountView(account: relationship.account!, mastodonController: mastodonController)
let host = UIHostingController(rootView: view)
self?.navigationDelegate?.present(host, animated: true)
}
}
}
}
private func fetchRelationship(accountID: String, mastodonController: MastodonController) async -> RelationshipMO? {

View File

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

View File

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

View File

@ -96,9 +96,15 @@ extension TuskerNavigationDelegate {
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil)
} else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
let nav = UINavigationController(rootViewController: compose)
nav.presentationController?.delegate = compose
present(nav, animated: true)
if #available(iOS 16.0, *),
presentDuckable(compose) {
return
} else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
let nav = UINavigationController(rootViewController: compose)
nav.presentationController?.delegate = compose
present(nav, animated: true)
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,45 @@
//
// AlertWithData.swift
// Tusker
//
// Created by Shadowfacts on 11/9/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import SwiftUI
struct AlertWithData<Data, A: View, M: View>: ViewModifier {
let title: LocalizedStringKey
@Binding var data: Data?
let actions: (Data) -> A
let message: (Data) -> M
private var isPresented: Binding<Bool> {
Binding(get: {
data != nil
}, set: { newValue in
guard !newValue else {
fatalError("Cannot set isPresented to true without data")
}
data = nil
})
}
init(title: LocalizedStringKey, data: Binding<Data?>, @ViewBuilder actions: @escaping (Data) -> A, @ViewBuilder message: @escaping (Data) -> M) {
self.title = title
self._data = data
self.actions = actions
self.message = message
}
func body(content: Content) -> some View {
content
.alert(title, isPresented: isPresented, presenting: data, actions: actions, message: message)
}
}
extension View {
func alertWithData<Data, A: View, M: View>(_ title: LocalizedStringKey, data: Binding<Data?>, @ViewBuilder actions: @escaping (Data) -> A, @ViewBuilder message: @escaping (Data) -> M) -> some View {
modifier(AlertWithData(title: title, data: data, actions: actions, message: message))
}
}

View File

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

View File

@ -1,71 +0,0 @@
//
// DraftsTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 10/22/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import Photos
class DraftTableViewCell: UITableViewCell {
@IBOutlet weak var contentWarningLabel: UILabel!
@IBOutlet weak var contentLabel: UILabel!
@IBOutlet weak var lastModifiedLabel: UILabel!
@IBOutlet weak var attachmentsStackViewContainer: UIView!
@IBOutlet weak var attachmentsStackView: UIStackView!
override func awakeFromNib() {
super.awakeFromNib()
contentWarningLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .bold))
contentWarningLabel.adjustsFontForContentSizeCategory = true
}
func updateUI(for draft: Draft) {
contentWarningLabel.text = draft.contentWarning
contentWarningLabel.isHidden = !draft.contentWarningEnabled
contentLabel.text = draft.text
lastModifiedLabel.text = draft.lastModified.timeAgoString()
attachmentsStackViewContainer.isHidden = draft.attachments.count == 0
for attachment in draft.attachments {
let size = CGSize(width: 50, height: 50)
let imageView = UIImageView(frame: CGRect(origin: .zero, size: size))
imageView.contentMode = .scaleAspectFill
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = 5
attachmentsStackView.addArrangedSubview(imageView)
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor).isActive = true
imageView.backgroundColor = .secondarySystemBackground
imageView.contentMode = .scaleAspectFill
switch attachment.data {
case let .asset(asset):
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
imageView.image = image
}
case let .image(image):
imageView.image = image
case .video(_):
// videos aren't saved to drafts, so this is unreachable
return
case let .drawing(drawing):
imageView.image = drawing.imageInLightMode(from: drawing.bounds)
imageView.backgroundColor = .white
imageView.contentMode = .scaleAspectFit
}
}
}
override func prepareForReuse() {
super.prepareForReuse()
attachmentsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
}
}

View File

@ -1,98 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="143" id="Q7N-Mt-RPF" customClass="DraftTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="143"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Q7N-Mt-RPF" id="KVi-jA-AET">
<rect key="frame" x="0.0" y="0.0" width="375" height="143"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="gaD-3B-qO1">
<rect key="frame" x="16" y="11" width="351" height="124"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Content Warning" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="VhS-ig-6Fu">
<rect key="frame" x="0.0" y="0.0" width="351" height="18"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="15"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="zMS-88-DcM">
<rect key="frame" x="0.0" y="26" width="351" height="40"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" ambiguous="YES" text="Content" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="3" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="8eA-yd-rBp">
<rect key="frame" x="0.0" y="0.0" width="310.5" height="32"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="D2X-9O-iQw">
<rect key="frame" x="326.5" y="0.0" width="24.5" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="D2X-9O-iQw" firstAttribute="leading" secondItem="8eA-yd-rBp" secondAttribute="trailing" constant="16" id="6Ux-ee-J5h"/>
<constraint firstAttribute="trailing" secondItem="D2X-9O-iQw" secondAttribute="trailing" id="IRH-mM-HSs"/>
<constraint firstItem="8eA-yd-rBp" firstAttribute="leading" secondItem="zMS-88-DcM" secondAttribute="leading" id="StS-F9-9B3"/>
<constraint firstItem="8eA-yd-rBp" firstAttribute="top" secondItem="zMS-88-DcM" secondAttribute="top" id="Uuq-g5-n0A"/>
<constraint firstItem="D2X-9O-iQw" firstAttribute="top" secondItem="zMS-88-DcM" secondAttribute="top" id="lWB-6Z-nbG"/>
<constraint firstAttribute="bottom" secondItem="8eA-yd-rBp" secondAttribute="bottom" id="zCK-s5-4Zo"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="csc-gx-KVg">
<rect key="frame" x="0.0" y="74" width="351" height="50"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" ambiguous="YES" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="htC-hf-vJ4">
<rect key="frame" x="0.0" y="0.0" width="352" height="50"/>
<constraints>
<constraint firstAttribute="height" constant="50" id="lxT-O2-afE"/>
</constraints>
</stackView>
</subviews>
<constraints>
<constraint firstItem="htC-hf-vJ4" firstAttribute="leading" secondItem="csc-gx-KVg" secondAttribute="leading" id="c0s-O9-XKa"/>
<constraint firstItem="htC-hf-vJ4" firstAttribute="top" secondItem="csc-gx-KVg" secondAttribute="top" id="lcl-RN-qHw"/>
<constraint firstAttribute="bottom" secondItem="htC-hf-vJ4" secondAttribute="bottom" id="oHX-Qh-bmI"/>
</constraints>
</view>
</subviews>
<constraints>
<constraint firstAttribute="trailing" secondItem="csc-gx-KVg" secondAttribute="trailing" id="AcZ-yc-8Zh"/>
</constraints>
</stackView>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="gaD-3B-qO1" secondAttribute="bottomMargin" constant="8" id="4Hz-ax-JI6"/>
<constraint firstItem="gaD-3B-qO1" firstAttribute="leading" secondItem="KVi-jA-AET" secondAttribute="leadingMargin" id="KRA-Q8-klX"/>
<constraint firstAttribute="trailing" secondItem="gaD-3B-qO1" secondAttribute="trailingMargin" constant="8" id="iGc-c4-n9y"/>
<constraint firstItem="gaD-3B-qO1" firstAttribute="top" secondItem="KVi-jA-AET" secondAttribute="topMargin" id="rVE-Jo-6zG"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="attachmentsStackView" destination="htC-hf-vJ4" id="kEX-m7-LuE"/>
<outlet property="attachmentsStackViewContainer" destination="csc-gx-KVg" id="rIM-pj-TFX"/>
<outlet property="contentLabel" destination="8eA-yd-rBp" id="Uy0-8G-WbU"/>
<outlet property="contentWarningLabel" destination="VhS-ig-6Fu" id="jIU-vr-OsY"/>
<outlet property="lastModifiedLabel" destination="D2X-9O-iQw" id="dx7-0E-RuM"/>
</connections>
<point key="canvasLocation" x="-388" y="184.85757121439281"/>
</tableViewCell>
</objects>
<resources>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@ -0,0 +1,98 @@
//
// MenuPicker.swift
// Tusker
//
// Created by Shadowfacts on 11/7/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import SwiftUI
struct MenuPicker<Value: Hashable>: UIViewRepresentable {
typealias UIViewType = UIButton
@Binding var selection: Value
let options: [Option]
var buttonStyle: ButtonStyle = .labelAndIcon
private var selectedOption: Option {
options.first(where: { $0.value == selection })!
}
func makeUIView(context: Context) -> UIButton {
let button = UIButton()
button.showsMenuAsPrimaryAction = true
button.setContentHuggingPriority(.required, for: .horizontal)
return button
}
func updateUIView(_ button: UIButton, context: Context) {
var config = UIButton.Configuration.borderless()
if #available(iOS 16.0, *) {
config.indicator = .popup
}
if buttonStyle.hasIcon {
config.image = selectedOption.image
}
if buttonStyle.hasLabel {
config.title = selectedOption.title
}
button.configuration = config
button.menu = UIMenu(children: options.map { opt in
UIAction(title: opt.title, subtitle: opt.subtitle, image: opt.image, state: opt.value == selection ? .on : .off) { _ in
selection = opt.value
}
})
button.accessibilityLabel = selectedOption.accessibilityLabel ?? selectedOption.title
button.isPointerInteractionEnabled = buttonStyle == .iconOnly
}
struct Option {
let value: Value
let title: String
let subtitle: String?
let image: UIImage?
let accessibilityLabel: String?
init(value: Value, title: String, subtitle: String? = nil, image: UIImage? = nil, accessibilityLabel: String? = nil) {
self.value = value
self.title = title
self.subtitle = subtitle
self.image = image
self.accessibilityLabel = accessibilityLabel
}
}
enum ButtonStyle {
case labelAndIcon, labelOnly, iconOnly
var hasLabel: Bool {
switch self {
case .labelAndIcon, .labelOnly:
return true
default:
return false
}
}
var hasIcon: Bool {
switch self {
case .labelAndIcon, .iconOnly:
return true
default:
return false
}
}
}
}
struct MenuPicker_Previews: PreviewProvider {
@State static var value = 0
static var previews: some View {
MenuPicker(selection: $value, options: [
.init(value: 0, title: "Zero"),
.init(value: 1, title: "One"),
.init(value: 2, title: "Two"),
])
}
}

View File

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

View File

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

View File

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

View File

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