Compare commits

...

53 Commits

Author SHA1 Message Date
Shadowfacts 9b30b48016 Bump build number and update changelog 2021-06-26 18:28:38 -04:00
Shadowfacts bd49683e13 Fix not being able to select assets on iOS 15 beta 2 2021-06-26 17:18:04 -04:00
Shadowfacts c22945b1e7 Use sheetPresentationController property 2021-06-26 17:02:17 -04:00
Shadowfacts 0a16a2e261 Fix potential data races 2021-06-26 16:51:54 -04:00
Shadowfacts b95819cada Fix crash when switching accounts 2021-06-26 16:42:56 -04:00
Shadowfacts dc1ea1bed9 Fix timeline momentum scrolling stopping due to adding footer section 2021-06-26 15:54:10 -04:00
Shadowfacts 5f9fe505d5 Add pref to disable infinite scrolling on timelines
Closes #125
2021-06-25 23:28:43 -04:00
Shadowfacts 5b8e97287e Convert TimelineTableViewController to use DiffableTimelineLikeTableViewController 2021-06-20 22:27:38 -04:00
Shadowfacts 49572c1fec Add DiffableTimelineLikeTableViewController 2021-06-20 22:27:29 -04:00
Shadowfacts ebb0770198 Add context menu action to remove attachments in Compose 2021-06-18 11:32:17 -04:00
Shadowfacts 27e05cc72d Enable focus loop debugging in debug 2021-06-12 22:17:59 -04:00
Shadowfacts 4ca48a5f50 Add iOS 15 compilation condition 2021-06-12 22:17:41 -04:00
Shadowfacts 230bd50661 Disable selection of presenting sidebar items on focus 2021-06-12 22:17:09 -04:00
Shadowfacts 4f2f8d517f Don't initiate table view cell drag while user is selecting poll options 2021-06-12 19:22:51 -04:00
Shadowfacts 130da9d4cc Improve status collapse animation
Use an additional label with no content and no height to absorb the
extra space creating during collapse when the content text view
disappears immediately.
2021-06-12 11:39:15 -04:00
Shadowfacts 472b9aa5e2 Fixes for large image animations on devices with square screns 2021-06-12 11:26:44 -04:00
Shadowfacts 3413dff8f9 Present compose screen in new window on iOS 15 and iPad/Mac 2021-06-11 10:50:31 -04:00
Shadowfacts 66e8fce488 Fix crash when conversation VC tries to restore from unloaded status 2021-06-11 10:19:59 -04:00
Shadowfacts aa2d333f4a Disable transparent nav bar on page view controllers 2021-06-10 10:55:09 -04:00
Shadowfacts c8a45d8eef Add Open in New Window menu item to profiles 2021-06-10 10:52:27 -04:00
Shadowfacts 40f5be28f6 Cleanup un/follow menu action 2021-06-10 10:36:02 -04:00
Shadowfacts 7c9287543c Fix crash due to PencilKit undo manager not being available until viewDidAppear 2021-06-10 10:33:24 -04:00
Shadowfacts 2a05b6d326 Add pointer hover effects to compose poll buttons 2021-06-09 19:18:54 -04:00
Shadowfacts 2499d25432 Use built-in sheet for asset picker on iOS 15 2021-06-09 19:12:10 -04:00
Shadowfacts 9417872790 Don't show Reply action in menu button on statuses 2021-06-09 17:10:44 -04:00
Shadowfacts c02a1bbf74 Make Pin status action title clearer 2021-06-09 17:10:13 -04:00
Shadowfacts 0a894b219a Allow Open in New Window action on iPadOS 2021-06-09 17:09:59 -04:00
Shadowfacts 22803668d2 Remove ellipsis from Share menu item title 2021-06-09 17:09:45 -04:00
Shadowfacts 2f6d1cb069 Use plain list style for Compose attachments 2021-06-09 17:08:59 -04:00
Shadowfacts 8889261b6b Fix compose reply avatar scroll effect not working on iOS 15 2021-06-09 11:01:11 -04:00
Shadowfacts 91f1a5195c Use visibility bar button item selection state instead of changing icon 2021-06-08 15:00:48 -04:00
Shadowfacts 1a5b958b1a Hide compose progress bar while there is no progress
On iOS 15, the progress bar displays a little bit of progress even at 0
2021-06-08 14:54:42 -04:00
Shadowfacts d667f6362c Use UniformTypeIdentifiers framework for everything 2021-06-07 20:08:46 -04:00
Shadowfacts ef1db466b9
Fix VoiceOver reading profile field names/values in incorrect order 2021-06-06 22:35:15 -04:00
Shadowfacts 0566f0ddfa
Fix More button in profile header not being VoiceOver accessible 2021-06-06 22:35:03 -04:00
Shadowfacts f54d4d757f
Make status attachments VoiceOver accessible 2021-06-06 22:31:11 -04:00
Shadowfacts fbc5d6eed9
Make timeline status cells single accessibility elements 2021-06-06 22:16:44 -04:00
Shadowfacts 2c4d2ce551
Make polls in statuses accessible 2021-06-06 22:11:29 -04:00
Shadowfacts bbe260bc9e
Construct PKToolPicker ourselves 2021-06-06 21:33:17 -04:00
Shadowfacts 2fe19a5abe
Add fast account switching indicator to tab bar item 2021-06-06 18:30:46 -04:00
Shadowfacts feacf576d7
Allow draging accounts in Preferences into new scenes 2021-06-06 14:55:18 -04:00
Shadowfacts ceb58f1d92
Add state restoration for current account in main scene 2021-06-06 14:55:04 -04:00
Shadowfacts 806591f5b7
Remove old framework from Xcode project 2021-05-24 19:30:20 -04:00
Shadowfacts 18ce21c2c6
Add Open in Tusker action extension 2021-05-24 19:30:11 -04:00
Shadowfacts 47fb0ea868
Update PLCrashReporter 2021-05-22 13:45:18 -04:00
Shadowfacts ffe6450b26
Xcode recommendations, use AnyObject instead of class in protocol requirements 2021-05-22 13:44:58 -04:00
Shadowfacts b51c1c03cb
Fix poll option percentages getting cut off
Closes #120
2021-05-22 11:44:50 -04:00
Shadowfacts e745d78d67
Fix polls not being collapsed inside CW
Closes #119
2021-05-22 11:30:56 -04:00
Shadowfacts 4c9d5e8465
Fix nav bar on iPad search screen hiding 2021-05-22 11:25:12 -04:00
Shadowfacts 9ec7177bfa
Fix crash when searching fails 2021-05-22 11:22:01 -04:00
Shadowfacts 421881d461
Remove dead code 2021-05-13 22:42:26 -04:00
Shadowfacts c78f152670
Animate attachment rows in when picking assets 2021-05-13 22:34:26 -04:00
Shadowfacts dabcae0905
Fix being unable to commit previewed profile from timeline status 2021-05-13 22:25:28 -04:00
81 changed files with 1961 additions and 375 deletions

View File

@ -1,5 +1,40 @@
# Changelog
## 2021.1 (20)
This is a big one! In addition to a bunch of fixes for anyone on the iOS 15 beta, there are a couple of big ticket features, including the Open in Tusker action extension and the Disable Infinite Scrolling preference.
Features/Improvements:
- Add Open in Tusker action extension
- Quickly search for any URL in Tusker
- In a share sheet, scroll to the bottom, tap "Edit Actions..." and turn on the "Open in Tusker" action
- Add Digital Wellness preference to disable infinite scrolling
- Add fast account switching indicator to My Profile tab
- Improve VoiceOver accessibility of polls and timeline statuses
- iPadOS: Create multiple main windows for different accounts by dragging from an account in Preferences
- iPadOS: Delete attachments on Compose screen by right-clicking and selecting Delete
- iPadOS 15: Add Open in New Window context menu action to most things
- iPadOS 15: Allow dragging the Compose sheet into a separate window
Bugfixes:
- Fix being unable to commit previewed account from timeline status
- Fix crash when searching fails
- Fix poll option percentages being cut off
- Fix polls not collapsing inside CWs
- Fix More button on profiles not being accessible with VoiceOver
- Fix VoiceOver reading profile fields in incorrect order
- Fix gallery animations jittering on devices with square screens (iPads, non-notched iPhones)
- Fix CW text jumping around post collapse animation
- iOS 15: Fix crash due when showing Draw Something screen in Compose
- iPadOS 14/iOS 15: Fix navigation bar turning transparent after opening the attachment gallery
- iPadOS 14/iOS 15: Fix drag-selecting poll options initiating a status cell drag interaction
- iPadOS: Fix crash when loading a previously-opened conversation window
- iPadOS 15: Fix showing Compose screen when keyboard focus moves through the sidebar
Known Issues:
- Disable Infinite Scrolling preference only affects timelines, not notifications or profiles
- iPadOS 15: The Compose sheet cannot be dismissed by swiping down
- iPadOS 15: Keyboard focus is stuck in the sidebar
## 2021.1 (19)
This is an emergency fix for Tusker breaking when connecting to Mastodon instances on 3.4.0rc1.

29
OpenInTusker/Action.js Normal file
View File

@ -0,0 +1,29 @@
//
// Action.js
// OpenInTusker
//
// Created by Shadowfacts on 5/22/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
var Action = function() {};
Action.prototype = {
run: function(arguments) {
const results = {
url: window.location.href,
};
const el = document.querySelector('link[rel=alternate][type="application/activity+json"]');
if (el) {
results.activityPubURL = el.href;
}
arguments.completionFunction(results);
},
finalize: function(arguments) {
}
};
var ExtensionPreprocessingJS = new Action();

View File

@ -0,0 +1,100 @@
//
// ActionViewController.swift
// OpenInTusker
//
// Created by Shadowfacts on 5/23/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import MobileCoreServices
class ActionViewController: UIViewController {
@IBOutlet weak var imageView: UIImageView!
override func viewDidLoad() {
super.viewDidLoad()
findURLFromWebPage { (components) in
if let components = components {
self.searchForURLInApp(components)
} else {
self.findURLItem { (components) in
if let components = components {
self.searchForURLInApp(components)
}
}
}
}
}
private func findURLFromWebPage(completion: @escaping (URLComponents?) -> Void) {
for item in extensionContext!.inputItems as! [NSExtensionItem] {
for provider in item.attachments! {
guard provider.hasItemConformingToTypeIdentifier(kUTTypePropertyList as String) else {
continue
}
provider.loadItem(forTypeIdentifier: kUTTypePropertyList as String, options: nil) { (result, error) in
guard let result = result as? [String: Any],
let jsResult = result[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any],
let urlString = jsResult["activityPubURL"] as? String ?? jsResult["url"] as? String,
let components = URLComponents(string: urlString) else {
completion(nil)
return
}
completion(components)
}
return
}
}
completion(nil)
}
private func findURLItem(completion: @escaping (URLComponents?) -> Void) {
for item in extensionContext!.inputItems as! [NSExtensionItem] {
for provider in item.attachments! {
guard provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) else {
continue
}
provider.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil) { (result, error) in
guard let result = result as? URL,
let components = URLComponents(url: result, resolvingAgainstBaseURL: false) else {
completion(nil)
return
}
completion(components)
}
return
}
}
completion(nil)
}
private func searchForURLInApp(_ components: URLComponents) {
var components = components
components.scheme = "tusker"
self.openURL(components.url!)
self.extensionContext!.completeRequest(returningItems: nil, completionHandler: nil)
}
@objc private func openURL(_ url: URL) {
var responder: UIResponder = self
while let parent = responder.next {
if let application = parent as? UIApplication {
application.perform(#selector(openURL(_:)), with: url)
break
} else {
responder = parent
}
}
}
@IBAction func done() {
extensionContext!.completeRequest(returningItems: nil, completionHandler: nil)
}
}

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="ObA-dk-sSI">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Image-->
<scene sceneID="7MM-of-jgj">
<objects>
<viewController title="Image" id="ObA-dk-sSI" customClass="ActionViewController" customModule="OpenInTusker" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="zMn-AG-sqS">
<rect key="frame" x="0.0" y="0.0" width="320" height="528"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<navigationBar contentMode="scaleToFill" horizontalCompressionResistancePriority="751" verticalCompressionResistancePriority="751" translatesAutoresizingMaskIntoConstraints="NO" id="NOA-Dm-cuz">
<rect key="frame" x="0.0" y="44" width="320" height="44"/>
<items>
<navigationItem id="3HJ-uW-3hn">
<barButtonItem key="leftBarButtonItem" title="Done" style="done" id="WYi-yp-eM6">
<connections>
<action selector="done" destination="ObA-dk-sSI" id="Qdu-qn-U6V"/>
</connections>
</barButtonItem>
</navigationItem>
</items>
</navigationBar>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Unable to find Mastodon link on this page." textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yho-gp-VyR">
<rect key="frame" x="0.0" y="254" width="320" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<viewLayoutGuide key="safeArea" id="VVe-Uw-JpX"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstItem="VVe-Uw-JpX" firstAttribute="trailing" secondItem="NOA-Dm-cuz" secondAttribute="trailing" id="A05-Pj-hrr"/>
<constraint firstItem="NOA-Dm-cuz" firstAttribute="leading" secondItem="VVe-Uw-JpX" secondAttribute="leading" id="HxO-8t-aoh"/>
<constraint firstItem="Yho-gp-VyR" firstAttribute="centerY" secondItem="zMn-AG-sqS" secondAttribute="centerY" id="R7q-OB-hhA"/>
<constraint firstItem="Yho-gp-VyR" firstAttribute="leading" secondItem="VVe-Uw-JpX" secondAttribute="leading" id="TEy-zi-dP7"/>
<constraint firstItem="Yho-gp-VyR" firstAttribute="trailing" secondItem="VVe-Uw-JpX" secondAttribute="trailing" id="Uvn-0x-Y6N"/>
<constraint firstItem="NOA-Dm-cuz" firstAttribute="top" secondItem="VVe-Uw-JpX" secondAttribute="top" id="we0-1t-bgp"/>
</constraints>
</view>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<size key="freeformSize" width="320" height="528"/>
<connections>
<outlet property="view" destination="zMn-AG-sqS" id="Qma-de-2ek"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="X47-rx-isc" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-61" y="-57"/>
</scene>
</scenes>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

55
OpenInTusker/Info.plist Normal file
View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Open in Tusker</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionJavaScriptPreprocessingFile</key>
<string>Action</string>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>
<key>NSExtensionServiceAllowsFinderPreviewItem</key>
<true/>
<key>NSExtensionServiceAllowsTouchBarItem</key>
<true/>
<key>NSExtensionServiceFinderPreviewIconName</key>
<string>NSActionTemplate</string>
<key>NSExtensionServiceTouchBarBezelColorName</key>
<string>TouchBarBezel</string>
<key>NSExtensionServiceTouchBarIconName</key>
<string>NSActionTemplate</string>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ui-services</string>
</dict>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 900 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,103 @@
{
"images" : [
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "60x60@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "60x60@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "76x76@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "83.5x83.5@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "1024x1024@1x.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,14 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
},
"colors" : [
{
"idiom" : "mac",
"color" : {
"reference" : "systemPurpleColor"
}
}
]
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@ -155,6 +155,7 @@
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; };
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; };
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */; };
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; };
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; };
@ -186,6 +187,7 @@
D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */; };
D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D679C09E215850EF00DA27FE /* XCBActions.swift */; };
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67B506C250B291200FAECFB /* BlurHashDecode.swift */; };
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */; };
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */; };
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */; };
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */; };
@ -303,10 +305,17 @@
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; };
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; };
D6DEA0DE268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DEA0DC268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift */; };
D6DEA0DF268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6DEA0DD268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib */; };
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */; };
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; };
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */; };
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0DC8D216EDF1E00369478 /* Previewing.swift */; };
D6E343AB265AAD6B00C4AA01 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6E343AA265AAD6B00C4AA01 /* Media.xcassets */; };
D6E343AD265AAD6B00C4AA01 /* ActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E343AC265AAD6B00C4AA01 /* ActionViewController.swift */; };
D6E343B0265AAD6B00C4AA01 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6E343AE265AAD6B00C4AA01 /* MainInterface.storyboard */; };
D6E343B4265AAD6B00C4AA01 /* OpenInTusker.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D6E343BA265AAD8C00C4AA01 /* Action.js in Resources */ = {isa = PBXBuildFile; fileRef = D6E343B9265AAD8C00C4AA01 /* Action.js */; };
D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */; };
D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */; };
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */; };
@ -363,9 +372,27 @@
remoteGlobalIDString = D6D4DDCB212518A000E1C4BB;
remoteInfo = Tusker;
};
D6E343B2265AAD6B00C4AA01 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = D6D4DDC4212518A000E1C4BB /* Project object */;
proxyType = 1;
remoteGlobalIDString = D6E343A7265AAD6B00C4AA01;
remoteInfo = OpenInTusker;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
D6E3438F2659849800C4AA01 /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
D6E343B4265AAD6B00C4AA01 /* OpenInTusker.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
D6F953E52125197500CF0F2B /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@ -388,7 +415,6 @@
0450531E22B0097E00100BA2 /* Timline+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timline+UI.swift"; sourceTree = "<group>"; };
04586B4022B2FFB10021BD04 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
04586B4222B301470021BD04 /* AppearancePrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePrefsView.swift; sourceTree = "<group>"; };
0461A38F2163CBAE00C0A807 /* Cache.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Cache.framework; sourceTree = BUILT_PRODUCTS_DIR; };
04D14BAE22B34A2800642648 /* GalleryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryViewController.swift; sourceTree = "<group>"; };
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = "<group>"; };
04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = "<group>"; };
@ -530,6 +556,7 @@
D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = "<group>"; };
D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = "<group>"; };
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffableTimelineLikeTableViewController.swift; sourceTree = "<group>"; };
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundableViewController.swift; sourceTree = "<group>"; };
D65F612D23AE990C00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D65F613023AE99E000F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -565,6 +592,7 @@
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAccountAvatarView.swift; sourceTree = "<group>"; };
D679C09E215850EF00DA27FE /* XCBActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActions.swift; sourceTree = "<group>"; };
D67B506C250B291200FAECFB /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastAccountSwitcherIndicatorView.swift; sourceTree = "<group>"; };
D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeAccountDetailView.swift; sourceTree = "<group>"; };
D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Uniques.swift"; sourceTree = "<group>"; };
D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposingPrefsView.swift; sourceTree = "<group>"; };
@ -683,10 +711,19 @@
D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = "<group>"; };
D6DEA0DC268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreTableViewCell.swift; sourceTree = "<group>"; };
D6DEA0DD268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfirmLoadMoreTableViewCell.xib; sourceTree = "<group>"; };
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationshipMO.swift; sourceTree = "<group>"; };
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = "<group>"; };
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakArray.swift; sourceTree = "<group>"; };
D6E0DC8D216EDF1E00369478 /* Previewing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Previewing.swift; sourceTree = "<group>"; };
D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenInTusker.appex; sourceTree = BUILT_PRODUCTS_DIR; };
D6E343AA265AAD6B00C4AA01 /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
D6E343AC265AAD6B00C4AA01 /* ActionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionViewController.swift; sourceTree = "<group>"; };
D6E343AF265AAD6B00C4AA01 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
D6E343B1265AAD6B00C4AA01 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D6E343B5265AAD6B00C4AA01 /* OpenInTusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenInTusker.entitlements; sourceTree = "<group>"; };
D6E343B9265AAD8C00C4AA01 /* Action.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = Action.js; sourceTree = "<group>"; };
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAutocompleteView.swift; sourceTree = "<group>"; };
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcher.swift; sourceTree = "<group>"; };
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcherTests.swift; sourceTree = "<group>"; };
@ -752,6 +789,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
D6E343A5265AAD6B00C4AA01 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@ -1413,6 +1457,7 @@
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */,
D67C57A721E2649B00C3118B /* Account Detail */,
D626494023C122C800612E6E /* Asset Picker */,
D61959D0241E842400A37B8E /* Draft Cell */,
@ -1424,6 +1469,7 @@
D6A3BC872321F78000FD64D5 /* Account Cell */,
D611C2CC232DC5FC00C86A49 /* Hashtag Cell */,
D61AC1DA232EA43100C54D2D /* Instance Cell */,
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */,
);
path = Views;
sourceTree = "<group>";
@ -1446,6 +1492,7 @@
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */,
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */,
D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */,
D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */,
D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */,
D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */,
);
@ -1466,12 +1513,12 @@
isa = PBXGroup;
children = (
D6BC874421961F73006163F1 /* Gifu.framework */,
0461A38F2163CBAE00C0A807 /* Cache.framework */,
D61099AC2144B0CC00432DC2 /* Pachyderm */,
D61099B92144B0CC00432DC2 /* PachydermTests */,
D6D4DDCE212518A000E1C4BB /* Tusker */,
D6D4DDE3212518A200E1C4BB /* TuskerTests */,
D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
D6E343A9265AAD6B00C4AA01 /* OpenInTusker */,
D6D4DDCD212518A000E1C4BB /* Products */,
D65A37F221472F300087646E /* Frameworks */,
);
@ -1485,6 +1532,7 @@
D6D4DDEB212518A200E1C4BB /* TuskerUITests.xctest */,
D61099AB2144B0CC00432DC2 /* Pachyderm.framework */,
D61099B32144B0CC00432DC2 /* PachydermTests.xctest */,
D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */,
);
name = Products;
sourceTree = "<group>";
@ -1552,6 +1600,28 @@
path = TuskerUITests;
sourceTree = "<group>";
};
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */ = {
isa = PBXGroup;
children = (
D6DEA0DC268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift */,
D6DEA0DD268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib */,
);
path = "Confirm Load More Cell";
sourceTree = "<group>";
};
D6E343A9265AAD6B00C4AA01 /* OpenInTusker */ = {
isa = PBXGroup;
children = (
D6E343B5265AAD6B00C4AA01 /* OpenInTusker.entitlements */,
D6E343AA265AAD6B00C4AA01 /* Media.xcassets */,
D6E343AC265AAD6B00C4AA01 /* ActionViewController.swift */,
D6E343AE265AAD6B00C4AA01 /* MainInterface.storyboard */,
D6E343B1265AAD6B00C4AA01 /* Info.plist */,
D6E343B9265AAD8C00C4AA01 /* Action.js */,
);
path = OpenInTusker;
sourceTree = "<group>";
};
D6F1F84E2193B9BE00F5FE67 /* Caching */ = {
isa = PBXGroup;
children = (
@ -1642,11 +1712,13 @@
D6D4DDCA212518A000E1C4BB /* Resources */,
D6F953E52125197500CF0F2B /* Embed Frameworks */,
D65F612C23AE957600F3CFD3 /* Embed debug-only frameworks */,
D6E3438F2659849800C4AA01 /* Embed App Extensions */,
);
buildRules = (
);
dependencies = (
D61099BF2144B0CC00432DC2 /* PBXTargetDependency */,
D6E343B3265AAD6B00C4AA01 /* PBXTargetDependency */,
);
name = Tusker;
packageProductDependencies = (
@ -1696,14 +1768,31 @@
productReference = D6D4DDEB212518A200E1C4BB /* TuskerUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
D6E343A7265AAD6B00C4AA01 /* OpenInTusker */ = {
isa = PBXNativeTarget;
buildConfigurationList = D6E343B6265AAD6B00C4AA01 /* Build configuration list for PBXNativeTarget "OpenInTusker" */;
buildPhases = (
D6E343A4265AAD6B00C4AA01 /* Sources */,
D6E343A5265AAD6B00C4AA01 /* Frameworks */,
D6E343A6265AAD6B00C4AA01 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = OpenInTusker;
productName = OpenInTusker;
productReference = D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
D6D4DDC4212518A000E1C4BB /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1200;
LastUpgradeCheck = 1020;
LastSwiftUpdateCheck = 1250;
LastUpgradeCheck = 1250;
ORGANIZATIONNAME = Shadowfacts;
TargetAttributes = {
D61099AA2144B0CC00432DC2 = {
@ -1729,6 +1818,9 @@
LastSwiftMigration = 1020;
TestTargetID = D6D4DDCB212518A000E1C4BB;
};
D6E343A7265AAD6B00C4AA01 = {
CreatedOnToolsVersion = 12.5;
};
};
};
buildConfigurationList = D6D4DDC7212518A000E1C4BB /* Build configuration list for PBXProject "Tusker" */;
@ -1760,6 +1852,7 @@
D6D4DDEA212518A200E1C4BB /* TuskerUITests */,
D61099AA2144B0CC00432DC2 /* Pachyderm */,
D61099B22144B0CC00432DC2 /* PachydermTests */,
D6E343A7265AAD6B00C4AA01 /* OpenInTusker */,
);
};
/* End PBXProject section */
@ -1810,6 +1903,7 @@
D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */,
D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */,
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */,
D6DEA0DF268400C300FE896A /* ConfirmLoadMoreTableViewCell.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -1827,6 +1921,16 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
D6E343A6265AAD6B00C4AA01 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D6E343BA265AAD8C00C4AA01 /* Action.js in Resources */,
D6E343AB265AAD6B00C4AA01 /* Media.xcassets in Resources */,
D6E343B0265AAD6B00C4AA01 /* MainInterface.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@ -1933,6 +2037,7 @@
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */,
D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */,
D6093FB725BE0CF3004811E6 /* HashtagHistoryView.swift in Sources */,
D6DEA0DE268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift in Sources */,
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */,
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */,
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */,
@ -2065,6 +2170,7 @@
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */,
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
@ -2085,6 +2191,7 @@
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,
D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */,
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
@ -2170,6 +2277,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
D6E343A4265AAD6B00C4AA01 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D6E343AD265AAD6B00C4AA01 /* ActionViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@ -2198,6 +2313,11 @@
target = D6D4DDCB212518A000E1C4BB /* Tusker */;
targetProxy = D6D4DDEC212518A200E1C4BB /* PBXContainerItemProxy */;
};
D6E343B3265AAD6B00C4AA01 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D6E343A7265AAD6B00C4AA01 /* OpenInTusker */;
targetProxy = D6E343B2265AAD6B00C4AA01 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
@ -2209,6 +2329,14 @@
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
D6E343AE265AAD6B00C4AA01 /* MainInterface.storyboard */ = {
isa = PBXVariantGroup;
children = (
D6E343AF265AAD6B00C4AA01 /* Base */,
);
name = MainInterface.storyboard;
sourceTree = "<group>";
};
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */ = {
isa = PBXVariantGroup;
children = (
@ -2234,6 +2362,7 @@
DYLIB_INSTALL_NAME_BASE = "@rpath";
INFOPLIST_FILE = Pachyderm/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2264,6 +2393,7 @@
DYLIB_INSTALL_NAME_BASE = "@rpath";
INFOPLIST_FILE = Pachyderm/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2346,6 +2476,7 @@
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
@ -2408,6 +2539,7 @@
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
@ -2445,11 +2577,11 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19;
CURRENT_PROJECT_VERSION = 20;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2461,6 +2593,10 @@
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos15.0]" = "SDK_IOS_15 $(inherited)";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator15.0]" = "SDK_IOS_15 $(inherited)";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=macosx12.0]" = "SDK_IOS_15 $(inherited)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6";
};
@ -2474,11 +2610,11 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19;
CURRENT_PROJECT_VERSION = 20;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2576,6 +2712,60 @@
};
name = Release;
};
D6E343B7265AAD6B00C4AA01 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2021.1;
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.OpenInTusker;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SUPPORTS_MACCATALYST = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6";
};
name = Debug;
};
D6E343B8265AAD6B00C4AA01 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2021.1;
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.OpenInTusker;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SUPPORTS_MACCATALYST = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@ -2633,6 +2823,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
D6E343B6265AAD6B00C4AA01 /* Build configuration list for PBXNativeTarget "OpenInTusker" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D6E343B7265AAD6B00C4AA01 /* Debug */,
D6E343B8265AAD6B00C4AA01 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
@ -2649,7 +2848,7 @@
repositoryURL = "https://github.com/microsoft/plcrashreporter";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 1.7.0;
minimumVersion = 1.8.0;
};
};
D6B0539D23BD2BA300A066FA /* XCRemoteSwiftPackageReference "SheetController" */ = {

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1020"
LastUpgradeVersion = "1250"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@ -29,8 +29,6 @@
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
<AdditionalOptions>
</AdditionalOptions>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@ -51,8 +49,6 @@
ReferencedContainer = "container:Tusker.xcodeproj">
</BuildableReference>
</MacroExpansion>
<AdditionalOptions>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1020"
LastUpgradeVersion = "1250"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
@ -88,6 +88,10 @@
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "-UIFocusLoopDebuggerEnabled YES"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable

View File

@ -6,8 +6,8 @@
"repositoryURL": "https://github.com/microsoft/plcrashreporter",
"state": {
"branch": null,
"revision": "6b7ca9a2faad6ea990ff60b0a3ee4fdf3db59150",
"version": "1.7.2"
"revision": "de6b8f9db4b2a0aa859a5507550a70548e4da936",
"version": "1.8.1"
}
},
{

View File

@ -65,6 +65,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
switch type {
case .mainScene:
return "main-scene"
case .showConversation,
.showTimeline,
.checkNotifications,
@ -76,9 +79,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
case .newPost:
return "compose"
default:
fatalError("no scene for activity type \(type)")
}
}
}

View File

@ -17,21 +17,39 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
return
}
guard LocalData.shared.onboardingComplete else {
UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil)
return
}
let account: LocalData.UserAccountInfo
let controller: MastodonController
let draft: Draft?
if let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity,
let activityAccount = UserActivityManager.getAccount(from: activity) {
if let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity {
if let activityAccount = UserActivityManager.getAccount(from: activity) {
account = activityAccount
draft = UserActivityManager.getDraft(from: activity)
} else {
// todo: this potentially changes the account for the draft, should show the same warning to user as in the drafts selection screen
account = LocalData.shared.getMostRecentAccount()!
}
controller = MastodonController.getForAccount(account)
if let activityDraft = UserActivityManager.getDraft(from: activity) {
draft = activityDraft
} else if let mentioning = activity.userInfo?["mentioning"] as? String {
draft = controller.createDraft(inReplyToID: nil, mentioningAcct: mentioning)
} else {
draft = nil
}
} else {
account = LocalData.shared.getMostRecentAccount()!
controller = MastodonController.getForAccount(account)
draft = nil
}
let controller = MastodonController.getForAccount(account)
session.mastodonController = controller
controller.getOwnAccount()
controller.getOwnInstance()

View File

@ -69,6 +69,7 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.bookmarks</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.my-profile</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-profile</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.main-scene</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>

View File

@ -15,12 +15,15 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private var launchActivity: NSUserActivity?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
guard let windowScene = scene as? UIWindowScene else { return }
if let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity {
launchActivity = activity
}
window = UIWindow(windowScene: windowScene)
if let report = AppDelegate.pendingCrashReport {
@ -28,6 +31,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
handlePendingCrashReport(report, session: session)
} else {
showAppOrOnboardingUI(session: session)
if connectionOptions.urlContexts.count > 0 {
self.scene(scene, openURLContexts: connectionOptions.urlContexts)
}
}
window!.makeKeyAndVisible()
@ -50,20 +56,10 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
if url.host == "x-callback-url" {
_ = XCBManager.handle(url: url)
} else if var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let tabBarController = window!.rootViewController as? MainTabBarViewController,
let exploreNavController = tabBarController.getTabController(tab: .explore) as? UINavigationController,
let exploreController = exploreNavController.viewControllers.first as? ExploreViewController {
tabBarController.select(tab: .explore)
exploreNavController.popToRootViewController(animated: false)
exploreController.loadViewIfNeeded()
exploreController.searchController.isActive = true
let rootViewController = window!.rootViewController as? TuskerRootViewController {
components.scheme = "https"
let query = url.absoluteString
exploreController.searchController.searchBar.text = query
exploreController.resultsController.performSearch(query: query)
let query = components.string!
rootViewController.performSearch(query: query)
}
}
@ -98,6 +94,14 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
DraftsManager.save()
}
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
if let mastodonController = window?.windowScene?.session.mastodonController {
return UserActivityManager.mainSceneActivity(accountID: mastodonController.accountInfo!.id)
} else {
return nil
}
}
func sceneWillEnterForeground(_ scene: UIScene) {
// Called as the scene transitions from the background to the foreground.
// Use this method to undo the changes made on entering the background.
@ -130,7 +134,14 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
func showAppOrOnboardingUI(session: UISceneSession? = nil) {
let session = session ?? window!.windowScene!.session
if LocalData.shared.onboardingComplete {
let account = LocalData.shared.getMostRecentAccount()!
let account: LocalData.UserAccountInfo
if let activity = launchActivity,
let activityAccount = UserActivityManager.getAccount(from: activity) {
account = activityAccount
} else {
account = LocalData.shared.getMostRecentAccount()!
}
if session.mastodonController == nil {
session.mastodonController = MastodonController.getForAccount(account)
}

View File

@ -8,7 +8,7 @@
import Foundation
import UIKit
import MobileCoreServices
import UniformTypeIdentifiers
final class CompositionAttachment: NSObject, Codable, ObservableObject {
static let typeIdentifier = "space.vaccor.Tusker.composition-attachment"
@ -52,10 +52,10 @@ final class CompositionAttachment: NSObject, Codable, ObservableObject {
extension CompositionAttachment: Identifiable {}
private let imageType = kUTTypeImage as String
private let mp4Type = kUTTypeMPEG4 as String
private let quickTimeType = kUTTypeQuickTimeMovie as String
private let dataType = kUTTypeData as String
private let imageType = UTType.image.identifier
private let mp4Type = UTType.mpeg4Movie.identifier
private let quickTimeType = UTType.quickTimeMovie.identifier
private let dataType = UTType.data.identifier
extension CompositionAttachment: NSItemProviderWriting {
static var writableTypeIdentifiersForItemProvider: [String] {
@ -100,11 +100,11 @@ extension CompositionAttachment: NSItemProviderReading {
return try PropertyListDecoder().decode(Self.self, from: data)
} else if UIImage.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let image = try? UIImage.object(withItemProviderData: data, typeIdentifier: typeIdentifier) {
return CompositionAttachment(data: .image(image)) as! Self
} else if typeIdentifier == mp4Type || typeIdentifier == quickTimeType {
} else if let type = UTType(typeIdentifier), type == .mpeg4Movie || type == .quickTimeMovie {
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let temporaryFileName = ProcessInfo().globallyUniqueString
let fileExt = UTTypeCopyPreferredTagWithClass(typeIdentifier as CFString, kUTTagClassFilenameExtension)!
let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName).appendingPathExtension(fileExt.takeUnretainedValue() as String)
let fileExt = type.preferredFilenameExtension!
let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(temporaryFileName).appendingPathExtension(fileExt)
try data.write(to: temporaryFileURL)
return CompositionAttachment(data: .video(temporaryFileURL)) as! Self
} else if NSURL.readableTypeIdentifiersForItemProvider.contains(typeIdentifier), let url = try? NSURL.object(withItemProviderData: data, typeIdentifier: typeIdentifier) as URL {

View File

@ -8,7 +8,7 @@
import UIKit
import Photos
import MobileCoreServices
import UniformTypeIdentifiers
import PencilKit
enum CompositionAttachmentData {
@ -74,7 +74,7 @@ enum CompositionAttachmentData {
data = context.jpegRepresentation(of: image, colorSpace: colorSpace, options: [:])!
mimeType = "image/jpeg"
} else {
mimeType = UTTypeCopyPreferredTagWithClass(dataUTI as CFString, kUTTagClassMIMEType)!.takeRetainedValue() as String
mimeType = UTType(dataUTI)!.preferredMIMEType!
}
completion(data, mimeType)

View File

@ -63,6 +63,7 @@ class Preferences: Codable, ObservableObject {
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false
self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false
self.silentActions = try container.decode([String: Permission].self, forKey: .silentActions)
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
@ -97,6 +98,7 @@ class Preferences: Codable, ObservableObject {
try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
try container.encode(grayscaleImages, forKey: .grayscaleImages)
try container.encode(disableInfiniteScrolling, forKey: .disableInfiniteScrolling)
try container.encode(silentActions, forKey: .silentActions)
try container.encode(statusContentType, forKey: .statusContentType)
@ -133,6 +135,7 @@ class Preferences: Codable, ObservableObject {
@Published var showFavoriteAndReblogCounts = true
@Published var defaultNotificationsMode = NotificationsMode.allNotifications
@Published var grayscaleImages = false
@Published var disableInfiniteScrolling = false
// MARK: Advanced
@Published var silentActions: [String: Permission] = [:]
@ -165,6 +168,7 @@ class Preferences: Codable, ObservableObject {
case showFavoriteAndReblogCounts
case defaultNotificationsType
case grayscaleImages
case disableInfiniteScrolling
case silentActions
case statusContentType

View File

@ -12,7 +12,7 @@ import Photos
private let reuseIdentifier = "assetCell"
private let cameraReuseIdentifier = "showCameraCell"
protocol AssetCollectionViewControllerDelegate: class {
protocol AssetCollectionViewControllerDelegate: AnyObject {
func shouldSelectAsset(_ asset: PHAsset) -> Bool
func didSelectAssets(_ assets: [PHAsset])
func captureFromCamera()
@ -70,6 +70,7 @@ class AssetCollectionViewController: UICollectionViewController {
collectionView.alwaysBounceVertical = true
collectionView.allowsMultipleSelection = true
collectionView.allowsSelection = true
collectionView.register(UINib(nibName: "AssetCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: reuseIdentifier)
collectionView.register(UINib(nibName: "ShowCameraCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: cameraReuseIdentifier)
@ -98,8 +99,6 @@ class AssetCollectionViewController: UICollectionViewController {
}
})
setEditing(true, animated: false)
updateItemsSelectedCount()
if let singleFingerPanGesture = collectionView.gestureRecognizers?.first(where: {

View File

@ -9,7 +9,7 @@
import UIKit
import Photos
protocol AssetPickerViewControllerDelegate: class {
protocol AssetPickerViewControllerDelegate: AnyObject {
func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool
func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData])
}

View File

@ -51,8 +51,14 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
}
var dismissInteractionController: LargeImageInteractionController?
var isInteractivelyAnimatingDismissal: Bool = false {
didSet {
setNeedsStatusBarAppearanceUpdate()
}
}
override var prefersStatusBarHidden: Bool {
return true
return !isInteractivelyAnimatingDismissal
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .none

View File

@ -41,6 +41,16 @@ struct ComposeAttachmentRow: View {
Label("Recognize Text", systemImage: "doc.text.viewfinder")
}
}
if #available(iOS 15.0, *) {
Button(action: self.removeAttachment) {
Label("Delete", systemImage: "trash")
}.foregroundStyle(.red)
} else {
Button(action: self.removeAttachment) {
Label("Delete", systemImage: "trash")
}
}
}
switch mode {
@ -126,8 +136,10 @@ struct ComposeAttachmentRow: View {
}
private func removeAttachment() {
withAnimation {
draft.attachments.removeAll { $0.id == attachment.id }
}
}
private func editDrawing() {
uiState.composeDrawingMode = .edit(id: attachment.id)

View File

@ -69,6 +69,7 @@ struct ComposeAttachmentsList: View {
.frame(height: cellHeight / 2)
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
}
.listStyle(PlainListStyle())
.frame(height: totalListHeight)
.onAppear(perform: self.didAppear)
.onReceive(draft.$attachments, perform: self.attachmentsChanged)

View File

@ -9,7 +9,7 @@
import UIKit
import PencilKit
protocol ComposeDrawingViewControllerDelegate: class {
protocol ComposeDrawingViewControllerDelegate: AnyObject {
func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController)
func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing)
}
@ -23,6 +23,8 @@ class ComposeDrawingViewController: UIViewController {
private(set) var undoBarButtonItem: UIBarButtonItem!
private(set) var redoBarButtonItem: UIBarButtonItem!
private var toolPicker: PKToolPicker!
private var initialDrawing: PKDrawing?
init() {
@ -70,27 +72,22 @@ class ComposeDrawingViewController: UIViewController {
canvasView.topAnchor.constraint(equalTo: view.topAnchor),
canvasView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// todo: should the PKToolPicker be owned by this VC or something else?
if let window = parent?.view.window, let toolPicker = PKToolPicker.shared(for: window) {
toolPicker = PKToolPicker()
toolPicker.setVisible(true, forFirstResponder: canvasView)
toolPicker.addObserver(canvasView)
toolPicker.addObserver(self)
updateLayout(for: toolPicker)
canvasView.becomeFirstResponder()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// wait until the next run loop iteration so that the canvas view has become first responder and it's undo manager exists
DispatchQueue.main.async {
NotificationCenter.default.addObserver(self, selector: #selector(self.updateUndoRedoButtonState), name: .NSUndoManagerDidUndoChange, object: self.undoManager!)
NotificationCenter.default.addObserver(self, selector: #selector(self.updateUndoRedoButtonState), name: .NSUndoManagerDidRedoChange, object: self.undoManager!)
}
}
}
func updateLayout(for toolPicker: PKToolPicker) {
let obscuredFrame = toolPicker.frameObscured(in: view)

View File

@ -11,7 +11,7 @@ import Combine
import Pachyderm
import PencilKit
protocol ComposeHostingControllerDelegate: class {
protocol ComposeHostingControllerDelegate: AnyObject {
func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool
}
@ -245,6 +245,25 @@ extension ComposeHostingController: ComposeUIStateDelegate {
}
func presentAssetPickerSheet() {
#if SDK_IOS_15
if #available(iOS 15.0, *) {
let picker = AssetPickerViewController()
picker.assetPickerDelegate = self
picker.modalPresentationStyle = .pageSheet
picker.overrideUserInterfaceStyle = .dark
let sheet = picker.sheetPresentationController!
sheet.detents = [.medium(), .large()]
sheet.prefersEdgeAttachedInCompactHeight = true
self.present(picker, animated: true)
} else {
presentOldAssetPickerSheet()
}
#else
presentOldAssetPickerSheet()
#endif
}
private func presentOldAssetPickerSheet() {
let sheetContainer = AssetPickerSheetContainerViewController()
sheetContainer.assetPicker.assetPickerDelegate = self
self.present(sheetContainer, animated: true)
@ -288,9 +307,11 @@ extension ComposeHostingController: AssetPickerViewControllerDelegate {
let attachments = attachments.map {
CompositionAttachment(data: $0)
}
withAnimation {
draft.attachments.append(contentsOf: attachments)
}
}
}
extension ComposeHostingController: DraftsTableViewControllerDelegate {
func draftSelectionCanceled() {

View File

@ -46,6 +46,7 @@ struct ComposePollView: View {
}
.accentColor(buttonForegroundColor)
.background(Circle().foregroundColor(buttonBackgroundColor))
.hoverEffect()
}
ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in
@ -161,6 +162,7 @@ struct ComposePollOption: View {
}
.foregroundColor(poll.options.count == 1 ? .gray : .red)
.disabled(poll.options.count == 1)
.hoverEffect()
}
}

View File

@ -52,9 +52,20 @@ struct ComposeReplyView: View {
private func replyAvatarImage(geometry: GeometryProxy) -> some View {
// using named coordinate spaces produces an incorrect scroll offset on iOS 13,
// so simply compare the geometry inside and outside the scroll view in the global coordinate space
var scrollOffset = outerMinY - geometry.frame(in: .global).minY
scrollOffset += stackPadding
let offset = min(max(scrollOffset, 0), geometry.size.height - 50 - stackPadding)
let scrollOffset = outerMinY - geometry.frame(in: .global).minY
// add stackPadding so that the image is always at least stackPadding away from the top
var offset = scrollOffset + stackPadding
// offset can never be less than 0 (i.e., above the top of the in-reply-to content)
offset = max(offset, 0)
// subtract 50, because we care about where the bottom of the view is but the offset is relative to the top of the view
let maxOffset = (contentHeight ?? 0) - 50
// once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
offset = min(offset, maxOffset)
return ComposeAvatarImageView(url: status.account.avatar)
.frame(width: 50, height: 50)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)

View File

@ -8,7 +8,7 @@
import UIKit
protocol ComposeTextViewCaretScrolling: class {
protocol ComposeTextViewCaretScrolling: AnyObject {
var caretScrollPositionAnimator: UIViewPropertyAnimator? { get set }
}

View File

@ -8,7 +8,7 @@
import SwiftUI
protocol ComposeUIStateDelegate: class {
protocol ComposeUIStateDelegate: AnyObject {
var assetPickerDelegate: AssetPickerViewControllerDelegate? { get }
func dismissCompose(mode: ComposeUIState.DismissMode)
@ -60,6 +60,6 @@ extension ComposeUIState {
}
}
protocol ComposeAutocompleteHandler: class {
protocol ComposeAutocompleteHandler: AnyObject {
func autocomplete(with string: String)
}

View File

@ -58,8 +58,10 @@ struct ComposeView: View {
}
}
if postProgress > 0 {
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
WrappedProgressView(value: postProgress, total: postTotalProgress)
}
autocompleteSuggestions
}

View File

@ -11,7 +11,7 @@ import Pachyderm
private let reuseIdentifier = "EmojiCell"
protocol EmojiPickerCollectionViewControllerDelegate: class {
protocol EmojiPickerCollectionViewControllerDelegate: AnyObject {
func selectedEmoji(_ emoji: Emoji)
}

View File

@ -132,6 +132,30 @@ class ConversationTableViewController: EnhancedTableViewController {
visibilityBarButtonItem = UIBarButtonItem(image: initialImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed))
navigationItem.rightBarButtonItem = visibilityBarButtonItem
loadMainStatus()
}
private func loadMainStatus() {
if let mainStatus = mastodonController.persistentContainer.status(for: mainStatusID) {
self.mainStatusLoaded(mainStatus)
} else {
let request = Client.getStatus(id: mainStatusID)
mastodonController.run(request) { (response) in
switch response {
case let .success(status, _):
let viewContext = self.mastodonController.persistentContainer.viewContext
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false, context: viewContext) { (statusMO) in
self.mainStatusLoaded(statusMO)
}
case .failure(_):
fatalError()
}
}
}
}
private func mainStatusLoaded(_ mainStatus: StatusMO) {
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
@ -139,12 +163,10 @@ class ConversationTableViewController: EnhancedTableViewController {
snapshot.appendItems([mainStatusItem], toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: false)
guard let mainStatus = self.mastodonController.persistentContainer.status(for: self.mainStatusID) else {
fatalError("Missing cached status \(self.mainStatusID)")
}
let mainStatusInReplyToID = mainStatus.inReplyToID
mainStatus.incrementReferenceCount()
// todo: it would be nice to cache these contexts
let request = Status.getContext(mainStatusID)
mastodonController.run(request) { response in
guard case let .success(context, _) = response else { fatalError() }
@ -304,7 +326,16 @@ class ConversationTableViewController: EnhancedTableViewController {
}
@objc func toggleVisibilityButtonPressed() {
#if SDK_IOS_15
if #available(iOS 15.0, *) {
visibilityBarButtonItem.isSelected = !visibilityBarButtonItem.isSelected
showStatusesAutomatically = visibilityBarButtonItem.isSelected
} else {
showStatusesAutomatically = !showStatusesAutomatically
}
#else
showStatusesAutomatically = !showStatusesAutomatically
#endif
let snapshot = dataSource.snapshot()
for case let .status(id: _, state: state) in snapshot.itemIdentifiers where state.collapsible == true {

View File

@ -8,7 +8,7 @@
import UIKit
protocol DraftsTableViewControllerDelegate: class {
protocol DraftsTableViewControllerDelegate: AnyObject {
func draftSelectionCanceled()
func shouldSelectDraft(_ draft: Draft, completion: @escaping (Bool) -> Void)
func draftSelected(_ draft: Draft)

View File

@ -8,7 +8,7 @@
import UIKit
protocol FastAccountSwitcherViewControllerDelegate: class {
protocol FastAccountSwitcherViewControllerDelegate: AnyObject {
func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool
}

View File

@ -51,8 +51,14 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
private var prevZoomScale: CGFloat?
private var isGrayscale = false
var isInteractivelyAnimatingDismissal: Bool = false {
didSet {
setNeedsStatusBarAppearanceUpdate()
}
}
override var prefersStatusBarHidden: Bool {
return true
return !isInteractivelyAnimatingDismissal
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .none

View File

@ -43,8 +43,14 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
var animationGifData: Data? { largeImageVC?.animationGifData }
var dismissInteractionController: LargeImageInteractionController?
var isInteractivelyAnimatingDismissal: Bool = false {
didSet {
setNeedsStatusBarAppearanceUpdate()
}
}
override var prefersStatusBarHidden: Bool {
return true
return !isInteractivelyAnimatingDismissal
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .none

View File

@ -15,6 +15,7 @@ protocol LargeImageAnimatableViewController: UIViewController {
var animationImage: UIImage? { get }
var animationGifData: Data? { get }
var dismissInteractionController: LargeImageInteractionController? { get }
var isInteractivelyAnimatingDismissal: Bool { get set }
}
extension LargeImageAnimatableViewController {
@ -74,7 +75,7 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
toVC.largeImageController?.contentView.isHidden = true
toVC.largeImageController?.setControlsVisible(false, animated: false)
var finalFrameSize = finalVCFrame.inset(by: fromVC.view.safeAreaInsets).size
var finalFrameSize = finalVCFrame.inset(by: toVC.view.safeAreaInsets).size
let newWidth = finalFrameSize.width / image.size.width
let newHeight = finalFrameSize.height / image.size.height
if newHeight < newWidth {

View File

@ -14,9 +14,9 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition {
var direction: CGFloat?
var shouldCompleteTransition = false
private weak var viewController: UIViewController!
private weak var viewController: LargeImageAnimatableViewController!
init(viewController: UIViewController) {
init(viewController: LargeImageAnimatableViewController) {
super.init()
self.viewController = viewController
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handleGesture(_:)))
@ -42,6 +42,7 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition {
viewController.dismiss(animated: true)
case .changed:
shouldCompleteTransition = progress > 0.5 || velocity > 1000
viewController.isInteractivelyAnimatingDismissal = progress > 0.1
update(progress)
case .cancelled:
inProgress = false
@ -59,4 +60,9 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition {
}
}
override func cancel() {
super.cancel()
viewController.isInteractivelyAnimatingDismissal = false
}
}

View File

@ -58,7 +58,7 @@ class ListTimelineViewController: TimelineTableViewController {
dismiss(animated: true)
// todo: show loading indicator
reloadInitialItems()
reloadInitial()
}
}

View File

@ -71,14 +71,21 @@ extension AccountSwitchingContainerViewController {
extension AccountSwitchingContainerViewController: TuskerRootViewController {
func presentCompose() {
loadViewIfNeeded()
root.presentCompose()
}
func select(tab: MainTabBarViewController.Tab) {
loadViewIfNeeded()
root.select(tab: tab)
}
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
root.getTabController(tab: tab)
loadViewIfNeeded()
return root.getTabController(tab: tab)
}
func performSearch(query: String) {
root.performSearch(query: query)
}
}

View File

@ -9,7 +9,7 @@
import UIKit
import Pachyderm
protocol MainSidebarViewControllerDelegate: class {
protocol MainSidebarViewControllerDelegate: AnyObject {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)
}
@ -48,7 +48,7 @@ class MainSidebarViewController: UIViewController {
private(set) var previouslySelectedItem: Item?
var selectedItem: Item? {
guard let indexPath = collectionView.indexPathsForSelectedItems?.first else {
guard let indexPath = collectionView?.indexPathsForSelectedItems?.first else {
return nil
}
return dataSource.itemIdentifier(for: indexPath)
@ -448,6 +448,20 @@ extension MainSidebarViewController: UICollectionViewDelegate {
sidebarDelegate?.sidebar(self, didSelectItem: item)
}
}
@available(iOS 15.0, *)
func collectionView(_ collectionView: UICollectionView, selectionFollowsFocusForItemAt indexPath: IndexPath) -> Bool {
guard let item = dataSource.itemIdentifier(for: indexPath) else {
return true
}
// don't immediately select items that present VCs when the they're focused, only when deliberately selected
switch item {
case .tab(.compose), .addList, .addSavedHashtag, .addSavedInstance:
return false
default:
return true
}
}
}
extension MainSidebarViewController: UICollectionViewDragDelegate {

View File

@ -19,6 +19,10 @@ class MainSplitViewController: UISplitViewController {
private var tabBarViewController: MainTabBarViewController!
private var secondaryNavController: UINavigationController! {
viewController(for: .secondary) as? UINavigationController
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if UIDevice.current.userInterfaceIdiom == .phone {
return .portrait
@ -67,8 +71,7 @@ class MainSplitViewController: UISplitViewController {
}
func select(item: MainSidebarViewController.Item) {
let nav = viewController(for: .secondary) as! UINavigationController
nav.viewControllers = getOrCreateNavigationStack(item: item)
secondaryNavController.viewControllers = getOrCreateNavigationStack(item: item)
}
func getOrCreateNavigationStack(item: MainSidebarViewController.Item) -> [UIViewController] {
@ -109,8 +112,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
private func transferNavigationStack(from item: MainSidebarViewController.Item, to destination: UINavigationController, dropFirst: Bool = false, append: Bool = false) {
var itemNavStack: [UIViewController]
if item == sidebar.selectedItem {
let detailNav = viewController(for: .secondary) as! UINavigationController
itemNavStack = detailNav.viewControllers
itemNavStack = secondaryNavController.viewControllers
} else {
itemNavStack = navigationStacks[item] ?? []
navigationStacks.removeValue(forKey: item)
@ -190,8 +192,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
// Make sure viewDidLoad is called so that the searchController/resultsController have been initialized
explore.loadViewIfNeeded()
let nav = viewController(for: .secondary) as! UINavigationController
let search = nav.viewControllers.first as! SearchViewController
let search = secondaryNavController.viewControllers.first as! SearchViewController
// Copy the search query from the search VC to the Explore VC's search controller.
let query = search.searchController.searchBar.text ?? ""
explore.searchController.searchBar.text = query
@ -318,9 +319,8 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate {
}
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) {
let nav = viewController(for: .secondary) as! UINavigationController
if let previous = sidebar.previouslySelectedItem {
navigationStacks[previous] = nav.viewControllers
navigationStacks[previous] = secondaryNavController.viewControllers
}
select(item: item)
}
@ -353,11 +353,18 @@ fileprivate extension MainSidebarViewController.Item {
extension MainSplitViewController: TuskerRootViewController {
@objc func presentCompose() {
if #available(iOS 15.0, *), UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
let compose = UserActivityManager.newPostActivity(mentioning: nil, accountID: mastodonController.accountInfo!.id)
let options = UIWindowScene.ActivationRequestOptions()
options.preferredPresentationStyle = .prominent
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil)
} else {
let vc = ComposeHostingController(mastodonController: mastodonController)
let nav = EnhancedNavigationViewController(rootViewController: vc)
nav.presentationController?.delegate = vc
present(nav, animated: true)
}
}
func select(tab: MainTabBarViewController.Tab) {
if traitCollection.horizontalSizeClass == .compact {
@ -365,12 +372,19 @@ extension MainSplitViewController: TuskerRootViewController {
} else {
if tab == .compose {
presentCompose()
} else {
if presentedViewController != nil {
dismiss(animated: true) {
self.select(item: .tab(tab))
self.sidebar.select(item: .tab(tab), animated: false)
}
} else {
select(item: .tab(tab))
sidebar.select(item: .tab(tab), animated: false)
}
}
}
}
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
if traitCollection.horizontalSizeClass == .compact {
@ -379,12 +393,43 @@ extension MainSplitViewController: TuskerRootViewController {
if tab == .compose {
return nil
} else if case .tab(tab) = sidebar.selectedItem {
return viewController(for: .secondary)
return secondaryNavController
} else {
return nil
}
}
}
func performSearch(query: String) {
guard traitCollection.horizontalSizeClass != .compact else {
// ensure the tab bar VC is loaded
loadViewIfNeeded()
tabBarViewController.performSearch(query: query)
return
}
if sidebar.selectedItem != .search {
select(item: .search)
}
guard let searchViewController = secondaryNavController.viewControllers.first as? SearchViewController else {
return
}
secondaryNavController.popToRootViewController(animated: false)
if searchViewController.isViewLoaded {
DispatchQueue.main.async {
searchViewController.searchController.isActive = true
}
} else {
searchViewController.searchControllerStatusOnAppearance = true
searchViewController.loadViewIfNeeded()
}
searchViewController.searchController.searchBar.text = query
searchViewController.resultsController.performSearch(query: query)
}
}
extension MainSplitViewController: BackgroundableViewController {

View File

@ -7,7 +7,6 @@
//
import UIKit
import SwiftUI
class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
@ -16,6 +15,9 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
private var composePlaceholder: UIViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController!
private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView!
private var fastSwitcherConstraints: [NSLayoutConstraint] = []
var selectedTab: Tab {
return Tab(rawValue: selectedIndex)!
}
@ -71,9 +73,62 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
tapRecognizer.cancelsTouchesInView = false
tabBar.addGestureRecognizer(tapRecognizer)
if findMyProfileTabBarButton() != nil {
fastSwitcherIndicator = FastAccountSwitcherIndicatorView()
fastSwitcherIndicator.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(fastSwitcherIndicator)
NSLayoutConstraint.activate([
fastSwitcherIndicator.widthAnchor.constraint(equalToConstant: 10),
fastSwitcherIndicator.heightAnchor.constraint(equalToConstant: 12),
])
}
tabBar.isSpringLoaded = true
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
repositionFastSwitcherIndicator()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
repositionFastSwitcherIndicator()
}
private func repositionFastSwitcherIndicator() {
guard let myProfileButton = findMyProfileTabBarButton() else {
return
}
NSLayoutConstraint.deactivate(fastSwitcherConstraints)
// using interfaceOrientation isn't ideal, but UITabBar buttons may lay out horizontally even in the compact size class
if traitCollection.horizontalSizeClass == .compact && interfaceOrientation.isPortrait {
fastSwitcherConstraints = [
fastSwitcherIndicator.centerYAnchor.constraint(equalTo: myProfileButton.centerYAnchor, constant: -4),
// tab bar button image width is 30
fastSwitcherIndicator.leftAnchor.constraint(equalTo: myProfileButton.centerXAnchor, constant: 15 + 2),
]
} else {
fastSwitcherConstraints = [
fastSwitcherIndicator.centerYAnchor.constraint(equalTo: myProfileButton.centerYAnchor),
fastSwitcherIndicator.trailingAnchor.constraint(equalTo: myProfileButton.trailingAnchor),
]
}
NSLayoutConstraint.activate(fastSwitcherConstraints)
}
private func findMyProfileTabBarButton() -> UIView? {
let tabBarButtons = tabBar.subviews.filter { String(describing: type(of: $0)).lowercased().contains("button") }
// sanity check that there is 1 button per VC
guard tabBarButtons.count == viewControllers!.count,
let myProfileButton = tabBarButtons.last else {
return nil
}
return myProfileButton
}
@objc private func tabBarTapped(_ recognizer: UITapGestureRecognizer) {
fastAccountSwitcher.hide()
}
@ -138,6 +193,8 @@ extension MainTabBarViewController {
if tab == .compose {
return nil
} else {
// viewWControllers array is setup in viewDidLoad
loadViewIfNeeded()
return viewControllers![tab.rawValue]
}
}
@ -145,13 +202,9 @@ extension MainTabBarViewController {
extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate {
func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool {
let tabBarButtons = tabBar.subviews.filter { String(describing: type(of: $0)).lowercased().contains("button") }
// sanity check that there is 1 button per VC
guard tabBarButtons.count == viewControllers!.count,
let myProfileButton = tabBarButtons.last else {
guard let myProfileButton = findMyProfileTabBarButton() else {
return false
}
let locationInButton = myProfileButton.convert(point, from: fastAccountSwitcher.view)
return myProfileButton.bounds.contains(locationInButton)
}
@ -159,21 +212,58 @@ extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate {
extension MainTabBarViewController: TuskerRootViewController {
@objc func presentCompose() {
if #available(iOS 15.0, *), UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
let compose = UserActivityManager.newPostActivity(mentioning: nil, accountID: mastodonController.accountInfo!.id)
let options = UIWindowScene.ActivationRequestOptions()
options.preferredPresentationStyle = .prominent
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil)
} else {
let vc = ComposeHostingController(mastodonController: mastodonController)
let nav = EnhancedNavigationViewController(rootViewController: vc)
nav.presentationController?.delegate = vc
present(nav, animated: true)
}
}
func select(tab: Tab) {
if tab == .compose {
presentCompose()
} else {
// when switching tabs, dismiss the currently presented VC
// otherwise the selected tab changes behind the presented VC
if presentedViewController != nil {
dismiss(animated: true) {
self.selectedIndex = tab.rawValue
}
} else {
selectedIndex = tab.rawValue
}
}
}
func performSearch(query: String) {
guard let exploreNavController = getTabController(tab: .explore) as? UINavigationController,
let exploreController = exploreNavController.viewControllers.first as? ExploreViewController else {
return
}
select(tab: .explore)
exploreNavController.popToRootViewController(animated: false)
// setting searchController.isActive directly doesn't work until the view has loaded/appeared for the first time
if exploreController.isViewLoaded {
exploreController.searchController.isActive = true
} else {
exploreController.searchControllerStatusOnAppearance = true
// we still need to load the view so that we can setup the search query
exploreController.loadViewIfNeeded()
}
exploreController.searchController.searchBar.text = query
exploreController.resultsController.performSearch(query: query)
}
}
extension MainTabBarViewController: BackgroundableViewController {
func sceneDidEnterBackground() {
if let selectedVC = selectedViewController as? BackgroundableViewController {

View File

@ -12,4 +12,5 @@ protocol TuskerRootViewController: UIViewController {
func presentCompose()
func select(tab: MainTabBarViewController.Tab)
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?
func performSearch(query: String)
}

View File

@ -10,7 +10,7 @@ import UIKit
import Combine
import Pachyderm
protocol InstanceSelectorTableViewControllerDelegate: class {
protocol InstanceSelectorTableViewControllerDelegate: AnyObject {
func didSelectInstance(url: URL)
}

View File

@ -36,6 +36,9 @@ struct PreferencesView: View {
.foregroundColor(.secondary)
}
}
}.onDrag {
let activity = UserActivityManager.mainSceneActivity(accountID: account.id)
return NSItemProvider(object: activity)
}
}.onDelete { (indices: IndexSet) in
var indices = indices

View File

@ -16,6 +16,7 @@ struct WellnessPrefsView: View {
showFavAndReblogCount
notificationsMode
grayscaleImages
disableInfiniteScrolling
}
.listStyle(InsetGroupedListStyle())
.navigationBarTitle(Text("Digital Wellness"))
@ -46,6 +47,14 @@ struct WellnessPrefsView: View {
}
}
}
private var disableInfiniteScrolling: some View {
Section(footer: Text("Require a button tap before loading more posts.")) {
Toggle(isOn: $preferences.disableInfiniteScrolling) {
Text("Disable Infinite Scrolling")
}
}
}
}
struct WellnessPrefsView_Previews: PreviewProvider {

View File

@ -91,11 +91,18 @@ class ProfileViewController: UIPageViewController {
addKeyCommand(MenuController.nextSubTabCommand)
accountUpdater = mastodonController.persistentContainer.accountSubject
.filter { [weak self] in $0 == self?.accountID }
.receive(on: DispatchQueue.main)
.filter { [weak self] in $0 == self?.accountID }
.sink { [weak self] (_) in self?.updateAccountUI() }
loadAccount()
// disable the transparent nav bar because it gets messy with multiple pages at different scroll positions
if let nav = navigationController {
let appearance = UINavigationBarAppearance()
appearance.configureWithDefaultBackground()
nav.navigationBar.scrollEdgeAppearance = appearance
}
}
override func viewDidAppear(_ animated: Bool) {
@ -245,34 +252,6 @@ extension ProfileViewController: ProfileHeaderViewDelegate {
view.pagesSegmentedControl.isUserInteractionEnabled = true
}
}
func profileHeader(_ view: ProfileHeaderView, showMoreOptionsFor accountID: String, sourceView: UIView) {
let account = mastodonController.persistentContainer.account(for: accountID)!
func showActivityController(activities: [UIActivity]) {
let activityController = UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: activities)
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(navigator: self, url: account.url)
activityController.popoverPresentationController?.sourceView = sourceView
self.present(activityController, animated: true)
}
if account.id == mastodonController.account.id {
showActivityController(activities: [OpenInSafariActivity()])
} else {
let request = Client.getRelationships(accounts: [account.id])
mastodonController.run(request) { (response) in
var customActivities: [UIActivity] = [OpenInSafariActivity()]
if case let .success(results, _) = response, let relationship = results.first {
let toggleFollowActivity = relationship.following ? UnfollowAccountActivity() : FollowAccountActivity()
customActivities.insert(toggleFollowActivity, at: 0)
}
DispatchQueue.main.async {
showActivityController(activities: customActivities)
}
}
}
}
}
extension ProfileViewController: TabBarScrollableViewController {

View File

@ -14,7 +14,7 @@ fileprivate let accountCell = "accountCell"
fileprivate let statusCell = "statusCell"
fileprivate let hashtagCell = "hashtagCell"
protocol SearchResultsViewControllerDelegate: class {
protocol SearchResultsViewControllerDelegate: AnyObject {
func selectedSearchResult(account accountID: String)
func selectedSearchResult(hashtag: Hashtag)
func selectedSearchResult(status statusID: String)
@ -36,6 +36,7 @@ class SearchResultsViewController: EnhancedTableViewController {
var dataSource: UITableViewDiffableDataSource<Section, Item>!
private var activityIndicator: UIActivityIndicatorView!
private var errorLabel: UILabel!
/// Types of results to search for. `nil` means all results will be included.
var resultTypes: [SearchResultType]? = nil
@ -61,6 +62,22 @@ class SearchResultsViewController: EnhancedTableViewController {
override func viewDidLoad() {
super.viewDidLoad()
errorLabel = UILabel()
errorLabel.translatesAutoresizingMaskIntoConstraints = false
errorLabel.font = .preferredFont(forTextStyle: .callout)
errorLabel.textColor = .secondaryLabel
errorLabel.numberOfLines = 0
errorLabel.textAlignment = .center
errorLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
tableView.addSubview(errorLabel)
NSLayoutConstraint.activate([
errorLabel.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
errorLabel.centerXAnchor.constraint(equalTo: tableView.centerXAnchor),
errorLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: tableView.leadingAnchor, multiplier: 1),
tableView.trailingAnchor.constraint(equalToSystemSpacingAfter: errorLabel.trailingAnchor, multiplier: 1),
])
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell)
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell)
tableView.register(UINib(nibName: "HashtagTableViewCell", bundle: .main), forCellReuseIdentifier: hashtagCell)
@ -131,19 +148,30 @@ class SearchResultsViewController: EnhancedTableViewController {
activityIndicator.isHidden = false
activityIndicator.startAnimating()
errorLabel.isHidden = true
let resultTypes = self.resultTypes
let request = Client.search(query: query, types: resultTypes, resolve: true, limit: 10)
mastodonController.run(request) { (response) in
guard case let .success(results, _) = response else { fatalError() }
switch response {
case let .success(results, _):
guard self.currentQuery == query else { return }
DispatchQueue.main.async {
self.activityIndicator.isHidden = true
self.activityIndicator.stopAnimating()
}
self.showSearchResults(results)
case let .failure(error):
DispatchQueue.main.async {
self.activityIndicator.isHidden = true
self.activityIndicator.stopAnimating()
guard self.currentQuery == query else { return }
self.showSearchError(error)
}
}
}
}
private func showSearchResults(_ results: SearchResults) {
let oldSnapshot = self.dataSource.snapshot()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
@ -162,6 +190,7 @@ class SearchResultsViewController: EnhancedTableViewController {
}
}
let resultTypes = self.resultTypes
if !results.accounts.isEmpty && (resultTypes == nil || resultTypes!.contains(.accounts)) {
snapshot.appendSections([.accounts])
snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts)
@ -178,10 +207,18 @@ class SearchResultsViewController: EnhancedTableViewController {
}
}, completion: {
DispatchQueue.main.async {
self.errorLabel.isHidden = true
self.dataSource.apply(snapshot)
}
})
}
private func showSearchError(_ error: Client.Error) {
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
dataSource.apply(snapshot)
errorLabel.isHidden = false
errorLabel.text = error.localizedDescription
}
// MARK: - Table view delegate

View File

@ -37,9 +37,10 @@ class SearchViewController: UIViewController {
resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController
searchController = UISearchController(searchResultsController: resultsController)
searchController.searchResultsUpdater = resultsController
searchController.obscuresBackgroundDuringPresentation = false
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.delegate = resultsController
searchController.hidesNavigationBarDuringPresentation = false
definesPresentationContext = true
navigationItem.searchController = searchController

View File

@ -8,7 +8,7 @@
import UIKit
protocol InstanceTimelineViewControllerDelegate: class {
protocol InstanceTimelineViewControllerDelegate: AnyObject {
func didSaveInstance(url: URL)
func didUnsaveInstance(url: URL)
}

View File

@ -11,7 +11,7 @@ import Pachyderm
typealias TimelineEntry = (id: String, state: StatusState)
class TimelineTableViewController: TimelineLikeTableViewController<TimelineEntry> {
class TimelineTableViewController: DiffableTimelineLikeTableViewController<TimelineTableViewController.Section, TimelineTableViewController.Item> {
let timeline: Timeline
weak var mastodonController: MastodonController!
@ -19,6 +19,8 @@ class TimelineTableViewController: TimelineLikeTableViewController<TimelineEntry
private var newer: RequestRange?
private var older: RequestRange?
private var didConfirmLoadMore = false
init(for timeline: Timeline, mastodonController: MastodonController) {
self.timeline = timeline
self.mastodonController = mastodonController
@ -40,115 +42,172 @@ class TimelineTableViewController: TimelineLikeTableViewController<TimelineEntry
}
deinit {
guard let persistentContainer = mastodonController?.persistentContainer else { return }
guard let persistentContainer = mastodonController?.persistentContainer,
let dataSource = dataSource else { return }
// decrement reference counts of any statuses we still have
// if the app is currently being quit, this will not affect the persisted data because
// the persistent container would already have been saved in SceneDelegate.sceneDidEnterBackground(_:)
for section in sections {
for (id, _) in section {
// todo: remove the whole reference count system
for case let .status(id: id, state: _) in dataSource.snapshot().itemIdentifiers {
persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell")
tableView.register(UINib(nibName: "ConfirmLoadMoreTableViewCell", bundle: .main), forCellReuseIdentifier: "confirmLoadMoreCell")
}
// MARK: - DiffableTimelineLikeTableViewController
override class func refreshCommandTitle() -> String {
return NSLocalizedString("Refresh Statuses", comment: "refresh status command discoverability title")
}
override func willRemoveRows(at indexPaths: [IndexPath]) {
for indexPath in indexPaths {
let id = item(for: indexPath).id
mastodonController.persistentContainer.status(for: id)?.decrementReferenceCount()
override func timelineContentSections() -> [Section] {
return [.statuses]
}
override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
switch item {
case let .status(id: id, state: state):
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
cell.delegate = self
cell.updateUI(statusID: id, state: state)
return cell
case .confirmLoadMore:
let cell = tableView.dequeueReusableCell(withIdentifier: "confirmLoadMoreCell", for: indexPath) as! ConfirmLoadMoreTableViewCell
cell.confirmLoadMore = {
self.didConfirmLoadMore = true
self.loadOlder()
self.didConfirmLoadMore = false
}
return cell
}
}
override func loadInitialItems(completion: @escaping ([TimelineEntry]) -> Void) {
let request = Client.getStatuses(timeline: timeline)
mastodonController?.run(request) { (response) in
guard case let .success(statuses, pagination) = response else {
completion([])
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
guard let mastodonController = mastodonController else {
completion(.failure(.noClient))
return
}
let request = Client.getStatuses(timeline: timeline)
mastodonController.run(request) { response in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
case let .success(statuses, pagination):
self.newer = pagination?.newer
self.older = pagination?.older
self.mastodonController?.persistentContainer.addAll(statuses: statuses) {
completion(statuses.map { ($0.id, .unknown) })
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = Snapshot()
snapshot.appendSections([.statuses, .footer])
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses)
completion(.success(snapshot))
}
}
}
}
override func loadOlder(completion: @escaping ([TimelineEntry]) -> Void) {
override func loadOlderItems(currentSnapshot: Snapshot, completion: @escaping (LoadResult) -> Void) {
guard let older = older else {
completion([])
completion(.failure(.noOlder))
return
}
guard !Preferences.shared.disableInfiniteScrolling || didConfirmLoadMore else {
guard !currentSnapshot.itemIdentifiers(inSection: .footer).contains(.confirmLoadMore) else {
// todo: need something more accurate than "success"/"failure"
completion(.success(currentSnapshot))
return
}
var snapshot = currentSnapshot
snapshot.appendItems([.confirmLoadMore], toSection: .footer)
self.dataSource.apply(snapshot)
completion(.success(snapshot))
return
}
let request = Client.getStatuses(timeline: timeline, range: older)
mastodonController.run(request) { (response) in
guard case let .success(statuses, pagination) = response else {
completion([])
return
}
mastodonController.run(request) { response in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
case let .success(statuses, pagination):
self.older = pagination?.older
self.mastodonController?.persistentContainer.addAll(statuses: statuses) {
completion(statuses.map { ($0.id, .unknown) })
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = currentSnapshot
snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }, toSection: .statuses)
snapshot.deleteItems([.confirmLoadMore])
completion(.success(snapshot))
}
}
}
}
override func loadNewer(completion: @escaping ([TimelineEntry]) -> Void) {
override func loadNewerItems(currentSnapshot: Snapshot, completion: @escaping (LoadResult) -> Void) {
guard let newer = newer else {
completion([])
completion(.failure(.noNewer))
return
}
let request = Client.getStatuses(timeline: timeline, range: newer)
mastodonController.run(request) { response in
switch response {
case let .failure(error):
completion(.failure(.client(error)))
mastodonController.run(request) { (response) in
guard case let .success(statuses, pagination) = response else {
completion([])
return
}
case let .success(statuses, pagination):
// if there are no new statuses, pagination is nil
// if we were to then overwrite self.newer, future refreshes would fail
// if we were to then overwrite self.newer, future refresh would fail
if let newer = pagination?.newer {
self.newer = newer
}
self.mastodonController?.persistentContainer.addAll(statuses: statuses) {
completion(statuses.map { ($0.id, .unknown) })
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
var snapshot = currentSnapshot
let identifiers = statuses.map { Item.status(id: $0.id, state: .unknown) }
if let first = snapshot.itemIdentifiers(inSection: .statuses).first {
snapshot.insertItems(identifiers, beforeItem: first)
} else {
snapshot.appendItems(identifiers, toSection: .statuses)
}
completion(.success(snapshot))
}
}
}
}
// MARK: - UITableViewDataSource
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as? TimelineStatusTableViewCell else { fatalError() }
let (id, state) = item(for: indexPath)
cell.delegate = self
cell.updateUI(statusID: id, state: state)
return cell
override func willRemoveItems(_ items: [Item]) {
for case let .status(id: id, state: _) in items {
mastodonController.persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
}
extension TimelineTableViewController {
enum Section: Hashable, CaseIterable {
case statuses
case footer
}
enum Item: Hashable {
case status(id: String, state: StatusState)
case confirmLoadMore
}
}
extension TimelineTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
}
@ -161,17 +220,23 @@ extension TimelineTableViewController: StatusTableViewCellDelegate {
extension TimelineTableViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
let ids = indexPaths.map { item(for: $0).id }
let ids: [String] = indexPaths.compactMap {
if case let .status(id: id, state: _) = dataSource.itemIdentifier(for: $0) {
return id
} else {
return nil
}
}
prefetchStatuses(with: ids)
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
let ids: [String] = indexPaths.compactMap {
guard $0.section < sections.count,
$0.row < sections[$0.section].count else {
if case let .status(id: id, state: _) = dataSource.itemIdentifier(for: $0) {
return id
} else {
return nil
}
return item(for: $0).id
}
cancelPrefetchingStatuses(with: ids)
}

View File

@ -0,0 +1,269 @@
//
// DiffableTimelineLikeTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 6/18/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable, Item: Hashable>: EnhancedTableViewController, RefreshableViewController {
typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>
typealias LoadResult = Result<Snapshot, LoadError>
private let pageSize = 20
private(set) var state = State.unloaded
private var lastLastVisibleRow: IndexPath?
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
init() {
super.init(style: .plain)
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: Self.refreshCommandTitle()))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: self.cellProvider)
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 140
#if !targetEnvironment(macCatalyst)
self.refreshControl = UIRefreshControl()
self.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
#endif
if let prefetchSource = self as? UITableViewDataSourcePrefetching {
tableView.prefetchDataSource = prefetchSource
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadInitial()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
pruneOffscreenRows()
}
class func refreshCommandTitle() -> String {
return "Refresh"
}
private func pruneOffscreenRows() {
guard let lastVisibleRow = lastLastVisibleRow else {
return
}
var snapshot = dataSource.snapshot()
let lastVisibleRowSection = snapshot.sectionIdentifiers[lastVisibleRow.section]
let contentSections = snapshot.sectionIdentifiers.filter { timelineContentSections().contains($0) }
guard let lastVisibleContentSectionIndex = contentSections.lastIndex(of: lastVisibleRowSection) else {
return
}
if lastVisibleContentSectionIndex < contentSections.count - 1 {
// there are more content sections below the current last visible one
let sectionsToRemove = contentSections[lastVisibleContentSectionIndex...]
snapshot.deleteSections(Array(sectionsToRemove))
willRemoveItems(sectionsToRemove.flatMap(snapshot.itemIdentifiers(inSection:)))
} else if lastVisibleContentSectionIndex == contentSections.count - 1 {
let items = snapshot.itemIdentifiers(inSection: lastVisibleRowSection)
if lastVisibleRow.row < items.count - pageSize {
let itemsToRemove = Array(items.suffix(pageSize))
snapshot.deleteItems(itemsToRemove)
willRemoveItems(itemsToRemove)
} else {
return
}
} else {
return
}
dataSource.apply(snapshot, animatingDifferences: false)
}
private func loadInitial() {
guard state == .unloaded else { return }
// set loaded immediately so we don't trigger another request while the current one is running
state = .loadingInitial
loadInitialItems() { result in
guard case let .success(snapshot) = result else {
self.state = .unloaded
return
}
DispatchQueue.main.async {
self.dataSource.apply(snapshot, animatingDifferences: false)
self.state = .loaded
}
}
}
func reloadInitial() {
state = .unloaded
loadInitial()
}
func loadOlder() {
guard state != .loadingOlder else { return }
state = .loadingOlder
loadOlderItems(currentSnapshot: dataSource.snapshot()) { result in
guard case let .success(snapshot) = result else {
self.state = .loaded
return
}
DispatchQueue.main.async {
self.dataSource.apply(snapshot, animatingDifferences: false)
self.state = .loaded
}
}
}
func cellHeightChanged() {
// causes the table view to recalculate the cell heights
tableView.beginUpdates()
tableView.endUpdates()
}
// MARK: - UITableViewDelegate
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// this assumes that indexPathsForVisibleRows is always in order
lastLastVisibleRow = tableView.indexPathsForVisibleRows?.last
let orderedContentSections = dataSource.snapshot().sectionIdentifiers.filter { timelineContentSections().contains($0) }
if indexPath.section == orderedContentSections.count - 1,
indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - 1 {
loadOlder()
}
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.leadingSwipeActionsConfiguration()
}
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
}
// MARK: - RefreshableViewController
func refresh() {
guard state != .loadingNewer else { return }
state = .loadingNewer
let snapshot = dataSource.snapshot()
var item: Item? = nil
for section in timelineContentSections() {
if let first = snapshot.itemIdentifiers(inSection: section).first {
item = first
break
}
}
loadNewerItems(currentSnapshot: snapshot) { result in
guard case let .success(snapshot) = result else {
DispatchQueue.main.async {
self.refreshControl?.endRefreshing()
self.state = .loaded
}
return
}
DispatchQueue.main.async {
self.refreshControl?.endRefreshing()
self.dataSource.apply(snapshot, animatingDifferences: false)
self.state = .loaded
if let item = item,
let indexPath = self.dataSource.indexPath(for: item) {
// maintain the current position in the list (don't scroll to top)
self.tableView.scrollToRow(at: indexPath, at: .top, animated: false)
}
}
}
}
// MARK: - Subclass Methods
func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? {
fatalError("cellProvider(_:_:_:) must be implemented by subclasses")
}
func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
fatalError("loadInitialItems(completion:) must be implemented by subclasses")
}
func loadOlderItems(currentSnapshot: Snapshot, completion: @escaping (LoadResult) -> Void) {
fatalError("loadOlderItesm(completion:) must be implemented by subclasses")
}
func loadNewerItems(currentSnapshot: Snapshot, completion: @escaping (LoadResult) -> Void) {
fatalError("loadNewerItems(completion:) must be implemented by subclasses")
}
func timelineContentSections() -> Section.AllCases {
return Section.allCases
}
func willRemoveItems(_ items: [Item]) {
}
}
extension DiffableTimelineLikeTableViewController {
enum State: Equatable {
case unloaded
case loadingInitial
case loaded
case loadingNewer
case loadingOlder
}
}
extension DiffableTimelineLikeTableViewController {
enum LoadError: LocalizedError {
case noClient
case noOlder
case noNewer
case client(Client.Error)
}
}
extension DiffableTimelineLikeTableViewController: BackgroundableViewController {
func sceneDidEnterBackground() {
pruneOffscreenRows()
}
}

View File

@ -10,7 +10,7 @@ import UIKit
import SafariServices
import Pachyderm
protocol MenuPreviewProvider: class {
protocol MenuPreviewProvider: AnyObject {
typealias PreviewProviders = (content: UIContextMenuContentPreviewProvider, actions: () -> [UIMenuElement])
@ -37,10 +37,11 @@ extension MenuPreviewProvider {
guard let mastodonController = mastodonController,
let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
guard mastodonController.loggedIn else {
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
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)
})
@ -63,13 +64,15 @@ extension MenuPreviewProvider {
let request = Client.getRelationships(accounts: [account.id])
// talk about callback hell :/
mastodonController.run(request) { [weak self] (response) in
if let self = self,
guard let self = self,
case let .success(results, _) = response,
let relationship = results.first {
let relationship = results.first else {
elementHandler([])
return
}
let following = relationship.following
DispatchQueue.main.async {
elementHandler([
self.createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus", handler: { (_) in
let action = self.createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus", handler: { (_) in
let request = (following ? Account.unfollow : Account.follow)(accountID)
mastodonController.run(request) { (response) in
switch response {
@ -80,21 +83,24 @@ extension MenuPreviewProvider {
}
}
})
elementHandler([
action
])
}
}
}
}))
}
let shareSection = [
var shareSection = [
openInSafariAction(url: account.url),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView)
})
]
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),
@ -104,7 +110,7 @@ extension MenuPreviewProvider {
func actionsForURL(_ url: URL, sourceView: UIView?) -> [UIAction] {
return [
openInSafariAction(url: url),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forURL: url, sourceView: sourceView)
})
@ -133,13 +139,14 @@ extension MenuPreviewProvider {
]
}
func actionsForStatus(_ status: StatusMO, sourceView: UIView?) -> [UIMenuElement] {
func actionsForStatus(_ status: StatusMO, sourceView: UIView?, includeReply: Bool = true) -> [UIMenuElement] {
guard let mastodonController = mastodonController else { return [] }
guard mastodonController.loggedIn else {
guard let accountID = mastodonController.accountInfo?.id else {
// user is logged out
return [
openInSafariAction(url: status.url!),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView)
})
@ -150,10 +157,6 @@ extension MenuPreviewProvider {
let muted = status.muted
var actionsSection = [
createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.compose(inReplyToID: status.id)
}),
createAction(identifier: "bookmark", title: bookmarked ? "Unbookmark" : "Bookmark", systemImageName: bookmarked ? "bookmark.fill" : "bookmark", handler: { [weak self] (_) in
guard let self = self else { return }
let request = (bookmarked ? Status.unbookmark : Status.bookmark)(status.id)
@ -174,9 +177,16 @@ extension MenuPreviewProvider {
})
]
if includeReply {
actionsSection.insert(createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.compose(inReplyToID: status.id)
}), at: 0)
}
if mastodonController.account != nil && mastodonController.account.id == status.account.id {
let pinned = status.pinned ?? false
actionsSection.append(createAction(identifier: "pin", title: pinned ? "Unpin" : "Pin", systemImageName: pinned ? "pin.slash" : "pin", handler: { [weak self] (_) in
actionsSection.append(createAction(identifier: "pin", title: pinned ? "Unpin from Profile" : "Pin to Profile", systemImageName: pinned ? "pin.slash" : "pin", handler: { [weak self] (_) in
guard let self = self else { return }
let request = (pinned ? Status.unpin : Status.pin)(status.id)
self.mastodonController?.run(request, completion: { [weak self] (response) in
@ -204,21 +214,13 @@ extension MenuPreviewProvider {
var shareSection = [
openInSafariAction(url: status.url!),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView)
}),
]
#if targetEnvironment(macCatalyst)
shareSection.append(createAction(identifier: "new_window", title: "Open in New Window", systemImageName: "", handler: { (_) in
guard let id = mastodonController.accountInfo?.id else {
return
}
// todo: this should try to find an existing session
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: id), options: nil, errorHandler: nil)
}))
#endif
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID))
return [
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
@ -242,6 +244,28 @@ extension MenuPreviewProvider {
})
}
private func addOpenInNewWindow(actions: inout [UIAction], activity: @escaping @autoclosure () -> NSUserActivity) {
#if SDK_IOS_15
if #available(iOS 15.0, *) {
let options = UIWindowScene.ActivationRequestOptions()
options.preferredPresentationStyle = .automatic
actions.append(UIWindowScene.ActivationAction { (_) in
return .init(userActivity: activity(), options: options, preview: nil)
})
} else if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
actions.append(createAction(identifier: "new_window", title: "Open in New Window", systemImageName: "rectangle.badge.plus", handler: { (_) in
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity(), options: nil, errorHandler: nil)
}))
}
#else
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
actions.append(createAction(identifier: "new_window", title: "Open in New Window", systemImageName: "rectangle.badge.plus", handler: { (_) in
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity(), options: nil, errorHandler: nil)
}))
}
#endif
}
}
extension LargeImageViewController: CustomPreviewPresenting {

View File

@ -44,6 +44,13 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
addKeyCommand(MenuController.prevSubTabCommand)
addKeyCommand(MenuController.nextSubTabCommand)
// disable the transparent nav bar because it gets messy with multiple pages at different scroll positions
if let nav = navigationController {
let appearance = UINavigationBarAppearance()
appearance.configureWithDefaultBackground()
nav.navigationBar.scrollEdgeAppearance = appearance
}
}
func selectPage(at index: Int, animated: Bool) {

View File

@ -9,8 +9,8 @@
import UIKit
/// A table view controller that manages common functionality between timeline-like UIs.
// For example, this class handles loading new items when the user scrolls to the end,
// refreshing, and pruning offscreen rows automatically.
/// For example, this class handles loading new items when the user scrolls to the end,
/// refreshing, and pruning offscreen rows automatically.
class TimelineLikeTableViewController<Item>: EnhancedTableViewController, RefreshableViewController {
private(set) var loaded = false

View File

@ -38,6 +38,15 @@ class UserActivityManager {
return LocalData.shared.getAccount(id: id)
}
// MARK: - Main Scene
static func mainSceneActivity(accountID: String) -> NSUserActivity {
let activity = NSUserActivity(type: .mainScene)
activity.userInfo = [
"accountID": accountID,
]
return activity
}
// MARK: - New Post
static func newPostActivity(mentioning: Account? = nil, accountID: String) -> NSUserActivity {
// todo: update to use managed objects
@ -61,6 +70,7 @@ class UserActivityManager {
// TODO: check not currently showing compose screen
let mentioning = activity.userInfo?["mentioning"] as? String
let draft = mastodonController.createDraft(mentioningAcct: mentioning)
// todo: this shouldn't use self.mastodonController, it should get the right one based on the userInfo accountID
let composeVC = ComposeHostingController(draft: draft, mastodonController: mastodonController)
present(UINavigationController(rootViewController: composeVC))
}

View File

@ -9,6 +9,7 @@
import Foundation
enum UserActivityType: String {
case mainScene = "space.vaccor.Tusker.activity.main-scene"
case newPost = "space.vaccor.Tusker.activity.new-post"
case checkNotifications = "space.vaccor.Tusker.activity.check-notifications"
case showTimeline = "space.vaccor.Tusker.activity.show-timeline"
@ -22,6 +23,8 @@ enum UserActivityType: String {
extension UserActivityType {
var handle: (NSUserActivity) -> Void {
switch self {
case .mainScene:
fatalError("cannot handle main scene activity")
case .newPost:
return UserActivityManager.handleNewPost
case .checkNotifications:

View File

@ -89,15 +89,22 @@ extension TuskerNavigationDelegate {
}
func compose(editing draft: Draft) {
if #available(iOS 15.0, *), UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
let compose = UserActivityManager.editDraftActivity(id: draft.id, accountID: apiController.accountInfo!.id)
let options = UIWindowScene.ActivationRequestOptions()
options.preferredPresentationStyle = .prominent
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil)
} else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
let vc = UINavigationController(rootViewController: compose)
vc.presentationController?.delegate = compose
present(vc, animated: true)
let nav = UINavigationController(rootViewController: compose)
nav.presentationController?.delegate = compose
present(nav, animated: true)
}
}
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) {
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)
DraftsManager.shared.add(draft)
compose(editing: draft)
}

View File

@ -11,7 +11,7 @@ import Pachyderm
import Gifu
import AVFoundation
protocol AttachmentViewDelegate: class {
protocol AttachmentViewDelegate: AnyObject {
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController?
func attachmentViewPresent(_ vc: UIViewController, animated: Bool)
}
@ -75,6 +75,9 @@ class AttachmentView: UIImageView, GIFAnimatable {
NotificationCenter.default.addObserver(self, selector: #selector(gifPlaybackModeChanged), name: .NSProcessInfoPowerStateDidChange, object: nil)
addInteraction(UIContextMenuInteraction(delegate: self))
isAccessibilityElement = true
accessibilityTraits = [.image, .button]
}
@objc private func preferencesChanged() {
@ -161,7 +164,9 @@ class AttachmentView: UIImageView, GIFAnimatable {
let attachmentURL = attachment.url
attachmentRequest = ImageCache.attachments.get(attachmentURL) { [weak self] (data, _) in
guard let self = self, let data = data else { return }
DispatchQueue.main.async {
self.attachmentRequest = nil
}
if self.attachment.url.pathExtension == "gif" {
self.source = .gifData(attachmentURL, data)
if self.autoplayGifs {
@ -294,6 +299,13 @@ class AttachmentView: UIImageView, GIFAnimatable {
showGallery()
}
// MARK: - Accessibility
override func accessibilityActivate() -> Bool {
showGallery()
return true
}
}
fileprivate extension AttachmentView {

View File

@ -248,9 +248,11 @@ class AttachmentsContainerView: UIView {
let attachmentView = AttachmentView(attachment: attachments[index], index: index, expectedSize: size)
attachmentView.delegate = delegate
attachmentView.translatesAutoresizingMaskIntoConstraints = false
attachmentView.isAccessibilityElement = true
attachmentView.accessibilityTraits = [.image, .button]
attachmentView.accessibilityLabel = String(format: NSLocalizedString("Attachment %d", comment: "attachment at index accessiblity label"), index + 1)
attachmentView.accessibilityLabel = "Attachment \(index + 1)"
if let desc = attachments[index].description {
attachmentView.accessibilityLabel! += ", \(desc)"
}
attachmentViews.add(attachmentView)
return attachmentView
}

View File

@ -11,7 +11,7 @@ import Pachyderm
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
protocol BaseEmojiLabel: class {
protocol BaseEmojiLabel: AnyObject {
var emojiIdentifier: String? { get set }
var emojiRequests: [ImageCache.Request] { get set }
var emojiFont: UIFont { get }

View File

@ -0,0 +1,55 @@
//
// ConfirmLoadMoreTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 6/23/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
protocol ConfirmLoadOlderTableViewCellDelegate: AnyObject {
func confirmLoadMore()
}
class ConfirmLoadMoreTableViewCell: UITableViewCell {
var confirmLoadMore: (() -> Void)?
@IBOutlet weak var confirmButton: UIButton!
private var isLoading = false
override func awakeFromNib() {
super.awakeFromNib()
if #available(iOS 15.0, *) {
var config = UIButton.Configuration.tinted()
config.title = "Load More"
config.showsActivityIndicator = false
config.imagePadding = 4
confirmButton.configuration = config
confirmButton.configurationUpdateHandler = { [unowned self] button in
button.configuration?.showsActivityIndicator = self.isLoading
}
}
}
override func prepareForReuse() {
super.prepareForReuse()
isLoading = false
if #available(iOS 15.0, *) {
confirmButton.setNeedsUpdateConfiguration()
}
}
@IBAction func loadMorePressed(_ sender: Any) {
confirmLoadMore?()
if #available(iOS 15.0, *) {
isLoading = true
confirmButton.setNeedsUpdateConfiguration()
}
}
}

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19115.3" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19107.5"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<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 contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="105" id="KGk-i7-Jjw" customClass="ConfirmLoadMoreTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="105"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="320" height="105"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="Rpx-45-c2n">
<rect key="frame" x="16" y="11" width="288" height="86"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Infinite scrolling is off. Do you want to keep going?" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="9nv-Re-5sL">
<rect key="frame" x="0.0" y="0.0" width="288" height="41"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="NT9-ly-efr">
<rect key="frame" x="0.0" y="45" width="288" height="41"/>
<state key="normal" title="Button"/>
<buttonConfiguration key="configuration" style="tinted" title="Load More"/>
<connections>
<action selector="loadMorePressed:" destination="KGk-i7-Jjw" eventType="touchUpInside" id="Pgz-MB-icB"/>
</connections>
</button>
</subviews>
</stackView>
</subviews>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
<constraints>
<constraint firstItem="Rpx-45-c2n" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="YfZ-rr-Omf"/>
<constraint firstItem="Rpx-45-c2n" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" id="hhi-yX-Wa4"/>
<constraint firstAttribute="trailingMargin" secondItem="Rpx-45-c2n" secondAttribute="trailing" id="jI8-St-34M"/>
<constraint firstAttribute="bottom" secondItem="Rpx-45-c2n" secondAttribute="bottom" constant="8" id="mQh-0l-Eo2"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<connections>
<outlet property="confirmButton" destination="NT9-ly-efr" id="Lja-th-LeH"/>
</connections>
<point key="canvasLocation" x="131.8840579710145" y="150.33482142857142"/>
</tableViewCell>
</objects>
<resources>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="secondarySystemBackgroundColor">
<color red="0.94901960784313721" green="0.94901960784313721" blue="0.96862745098039216" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@ -0,0 +1,30 @@
//
// FastAccountSwitchingIndicatorView.swift
// Tusker
//
// Created by Shadowfacts on 6/6/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
class FastAccountSwitcherIndicatorView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
tintColor = .lightGray
let up = UIImageView(image: UIImage(systemName: "arrowtriangle.up.fill")!)
up.frame = CGRect(x: 0, y: 0, width: 10, height: 5)
addSubview(up)
let down = UIImageView(image: UIImage(systemName: "arrowtriangle.down.fill")!)
down.frame = CGRect(x: 0, y: 7, width: 10, height: 5)
addSubview(down)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -30,6 +30,7 @@ class PollOptionView: UIView {
let label = EmojiLabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.numberOfLines = 0
label.font = .preferredFont(forTextStyle: .callout)
label.text = option.title
label.setEmojis(poll.emojis, identifier: poll.id)
addSubview(label)
@ -37,15 +38,21 @@ class PollOptionView: UIView {
let percentLabel = UILabel()
percentLabel.translatesAutoresizingMaskIntoConstraints = false
percentLabel.text = "100%"
percentLabel.font = label.font
percentLabel.isHidden = true
percentLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
percentLabel.setContentHuggingPriority(.required, for: .horizontal)
addSubview(percentLabel)
accessibilityLabel = option.title
if (poll.voted ?? false) || poll.effectiveExpired,
let optionVotes = option.votesCount {
let frac = poll.votesCount == 0 ? 0 : CGFloat(optionVotes) / CGFloat(poll.votesCount)
let percent = String(format: "%.0f%%", frac * 100)
percentLabel.isHidden = false
percentLabel.text = String(format: "%.0f%%", frac * 100)
percentLabel.text = percent
let fillView = UIView()
fillView.translatesAutoresizingMaskIntoConstraints = false
@ -60,6 +67,8 @@ class PollOptionView: UIView {
fillView.topAnchor.constraint(equalTo: topAnchor),
fillView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
accessibilityLabel! += ", \(percent)"
}
let minHeightConstraint = heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
@ -76,12 +85,14 @@ class PollOptionView: UIView {
label.topAnchor.constraint(equalTo: topAnchor),
label.bottomAnchor.constraint(equalTo: bottomAnchor),
label.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 8),
label.trailingAnchor.constraint(equalTo: percentLabel.leadingAnchor),
label.trailingAnchor.constraint(equalTo: percentLabel.leadingAnchor, constant: -4),
percentLabel.topAnchor.constraint(equalTo: topAnchor),
percentLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
percentLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
])
isAccessibilityElement = true
}
required init?(coder: NSCoder) {

View File

@ -69,6 +69,8 @@ class PollOptionsView: UIControl {
stack.addArrangedSubview(optionView)
return optionView
}
accessibilityElements = options
}
private func selectOption(_ option: PollOptionView) {

View File

@ -23,17 +23,20 @@ class StatusPollView: UIView {
weak var mastodonController: MastodonController!
private var statusID: String!
private var poll: Poll!
private(set) var poll: Poll?
private var optionsView: PollOptionsView!
private var voteButton: UIButton!
private var infoLabel: UILabel!
private var options: [PollOptionView] = []
private var canVote = true
private var animator: UIViewPropertyAnimator!
private var currentSelectedOptionIndex: Int!
var isTracking: Bool {
optionsView.isTracking
}
override func awakeFromNib() {
super.awakeFromNib()
@ -74,13 +77,15 @@ class StatusPollView: UIView {
voteButton.topAnchor.constraint(equalTo: optionsView.bottomAnchor),
voteButton.bottomAnchor.constraint(equalTo: bottomAnchor),
])
accessibilityElements = [optionsView!, infoLabel!, voteButton!]
}
func updateUI(status: StatusMO, poll: Poll) {
func updateUI(status: StatusMO, poll: Poll?) {
self.statusID = status.id
self.poll = poll
options.forEach { $0.removeFromSuperview() }
guard let poll = poll else { return }
// poll.voted is nil if there is no user (e.g., public timeline), in which case the poll also cannot be voted upon
if (poll.voted ?? true) || poll.expired || status.account.id == mastodonController.account.id {
@ -138,7 +143,7 @@ class StatusPollView: UIView {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
let request = Poll.vote(poll.id, choices: optionsView.checkedOptionIndices)
let request = Poll.vote(poll!.id, choices: optionsView.checkedOptionIndices)
mastodonController.run(request) { (response) in
switch response {
case let .failure(error):

View File

@ -12,8 +12,6 @@ import Combine
protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate {
func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int)
func profileHeader(_ headerView: ProfileHeaderView, showMoreOptionsFor accountID: String, sourceView: UIView)
}
class ProfileHeaderView: UIView {
@ -82,14 +80,14 @@ class ProfileHeaderView: UIView {
cancellables = []
mastodonController.persistentContainer.accountSubject
.filter { [weak self] in $0 == self?.accountID }
.receive(on: DispatchQueue.main)
.filter { [weak self] in $0 == self?.accountID }
.sink { [weak self] in self?.updateUI(for: $0) }
.store(in: &cancellables)
mastodonController.persistentContainer.relationshipSubject
.filter { [weak self] in $0 == self?.accountID }
.receive(on: DispatchQueue.main)
.filter { [weak self] in $0 == self?.accountID }
.sink { [weak self] (_) in self?.updateRelationship() }
.store(in: &cancellables)
}
@ -134,6 +132,7 @@ class ProfileHeaderView: UIView {
fieldNamesStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
fieldValuesStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
var fieldAccessibilityElements = [Any]()
for field in account.fields {
let nameLabel = EmojiLabel()
nameLabel.text = field.name
@ -157,7 +156,18 @@ class ProfileHeaderView: UIView {
fieldValuesStackView.addArrangedSubview(valueTextView)
nameLabel.heightAnchor.constraint(equalTo: valueTextView.heightAnchor).isActive = true
fieldAccessibilityElements.append(nameLabel)
fieldAccessibilityElements.append(valueTextView)
}
accessibilityElements = [
displayNameLabel!,
usernameLabel!,
noteTextView!,
] + fieldAccessibilityElements + [
moreButton!,
pagesSegmentedControl!,
]
}
private func updateRelationship() {
@ -193,11 +203,14 @@ class ProfileHeaderView: UIView {
let image = image,
self.accountID == accountID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
DispatchQueue.main.async {
self?.avatarRequest = nil
}
return
}
self.avatarRequest = nil
DispatchQueue.main.async {
self.avatarRequest = nil
self.avatarImageView.image = transformedImage
}
}
@ -207,11 +220,14 @@ class ProfileHeaderView: UIView {
let image = image,
self.accountID == accountID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: header, image: image) else {
DispatchQueue.main.async {
self?.headerRequest = nil
}
return
}
self.headerRequest = nil
DispatchQueue.main.async {
self.headerRequest = nil
self.headerImageView.image = transformedImage
}
}

View File

@ -94,8 +94,7 @@ class BaseStatusTableViewCell: UITableViewCell, MenuPreviewProvider {
collapseButton.layer.masksToBounds = true
collapseButton.layer.cornerRadius = 5
accessibilityElements = [displayNameLabel!, contentWarningLabel!, collapseButton!, contentTextView!, attachmentsView!]
attachmentsView.isAccessibilityElement = true
accessibilityElements = [displayNameLabel!, contentWarningLabel!, collapseButton!, contentTextView!, attachmentsView!, pollView!]
moreButton.showsMenuAsPrimaryAction = true
@ -107,8 +106,8 @@ class BaseStatusTableViewCell: UITableViewCell, MenuPreviewProvider {
open func createObserversIfNecessary() {
if statusUpdater == nil {
statusUpdater = mastodonController.persistentContainer.statusSubject
.filter { [unowned self] in $0 == self.statusID }
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.statusID }
.sink { [unowned self] in
if let mastodonController = mastodonController,
let status = mastodonController.persistentContainer.status(for: $0) {
@ -119,8 +118,8 @@ class BaseStatusTableViewCell: UITableViewCell, MenuPreviewProvider {
if accountUpdater == nil {
accountUpdater = mastodonController.persistentContainer.accountSubject
.filter { [unowned self] in $0 == self.accountID }
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.accountID }
.sink { [unowned self] in
if let mastodonController = mastodonController,
let account = mastodonController.persistentContainer.account(for: $0) {
@ -157,8 +156,6 @@ class BaseStatusTableViewCell: UITableViewCell, MenuPreviewProvider {
cardView.navigationDelegate = navigationDelegate
attachmentsView.updateUI(status: status)
attachmentsView.isAccessibilityElement = status.attachments.count > 0
attachmentsView.accessibilityLabel = String(format: NSLocalizedString("%d attachments", comment: "status attachments count accessibility label"), status.attachments.count)
updateStatusState(status: status)
@ -213,15 +210,12 @@ class BaseStatusTableViewCell: UITableViewCell, MenuPreviewProvider {
}
// keep menu in sync with changed states e.g. bookmarked, muted
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForStatus(status, sourceView: moreButton))
// do not include reply action here, because the cell already contains a button for it
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForStatus(status, sourceView: moreButton, includeReply: false))
if let poll = status.poll {
pollView.isHidden = false
pollView.isHidden = status.poll == nil
pollView.mastodonController = mastodonController
pollView.updateUI(status: status, poll: poll)
} else {
pollView.isHidden = true
}
pollView.updateUI(status: status, poll: status.poll)
}
func updateUI(account: AccountMO) {
@ -359,6 +353,7 @@ class BaseStatusTableViewCell: UITableViewCell, MenuPreviewProvider {
contentTextView.isHidden = collapsed
cardView.isHidden = cardView.card == nil || collapsed
attachmentsView.isHidden = attachmentsView.attachments.count == 0 || collapsed
pollView.isHidden = pollView.poll == nil || collapsed
let buttonImage = UIImage(systemName: collapsed ? "chevron.down" : "chevron.up")!

View File

@ -33,7 +33,21 @@ class ConversationMainStatusTableViewCell: BaseStatusTableViewCell {
profileAccessibilityElement = UIAccessibilityElement(accessibilityContainer: self)
profileAccessibilityElement.accessibilityFrameInContainerSpace = profileDetailContainerView.convert(profileDetailContainerView.frame, to: self)
accessibilityElements = [profileAccessibilityElement!, contentWarningLabel!, collapseButton!, contentTextView!, totalFavoritesButton!, totalReblogsButton!, timestampAndClientLabel!, replyButton!, favoriteButton!, reblogButton!, moreButton!]
accessibilityElements = [
profileAccessibilityElement!,
contentWarningLabel!,
collapseButton!,
contentTextView!,
attachmentsView!,
pollView!,
totalFavoritesButton!,
totalReblogsButton!,
timestampAndClientLabel!,
replyButton!,
favoriteButton!,
reblogButton!,
moreButton!,
]
contentTextView.defaultFont = .systemFont(ofSize: 18)

View File

@ -15,7 +15,7 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
static let relativeDateFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.dateTimeStyle = .numeric
formatter.unitsStyle = .short
formatter.unitsStyle = .full
return formatter
}()
@ -44,7 +44,7 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
reblogLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reblogLabelPressed)))
accessibilityElements!.insert(reblogLabel!, at: 0)
isAccessibilityElement = true
// todo: double check this on RTL layouts
replyButton.imageView!.leadingAnchor.constraint(equalTo: contentTextView.leadingAnchor).isActive = true
@ -59,8 +59,8 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
if rebloggerAccountUpdater == nil {
rebloggerAccountUpdater = mastodonController.persistentContainer.accountSubject
.filter { [unowned self] in $0 == self.rebloggerID }
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.rebloggerID }
.sink { [unowned self] in
if let mastodonController = self.mastodonController,
let reblogger = mastodonController.persistentContainer.account(for: $0) {
@ -134,7 +134,6 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
private func doUpdateTimestamp(status: StatusMO) {
timestampLabel.text = status.createdAt.timeAgoString()
timestampLabel.accessibilityLabel = TimelineStatusTableViewCell.relativeDateFormatter.localizedString(for: status.createdAt, relativeTo: Date())
let delay: DispatchTimeInterval?
switch status.createdAt.timeAgo().1 {
@ -194,6 +193,38 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
)
}
// MARK: - Accessibility
override var accessibilityLabel: String? {
get {
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil
}
var str = "\(status.account.displayName), \(contentTextView.text ?? "")"
if status.attachments.count > 0 {
// todo: localize me
str += ", \(status.attachments.count) attachments"
}
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)"
}
return str
}
set {}
}
override func accessibilityActivate() -> Bool {
didSelectCell()
return true
}
}
extension TimelineStatusTableViewCell: SelectableTableViewCell {
@ -295,8 +326,11 @@ extension TimelineStatusTableViewCell: TableViewSwipeActionProvider {
extension TimelineStatusTableViewCell: DraggableTableViewCell {
func dragItemsForBeginning(session: UIDragSession) -> [UIDragItem] {
// the poll options view is tracking while the user is dragging between options
// while that's happening, don't initiate a drag
guard let status = mastodonController.persistentContainer.status(for: statusID),
let accountID = mastodonController.accountInfo?.id else {
let accountID = mastodonController.accountInfo?.id,
!pollView.isTracking else {
return []
}
let provider = NSItemProvider(object: status.url! as NSURL)
@ -314,4 +348,19 @@ extension TimelineStatusTableViewCell: UIContextMenuInteractionDelegate {
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: self.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView))
}
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
if let viewController = animator.previewViewController,
let delegate = navigationDelegate {
animator.preferredCommitStyle = .pop
animator.addCompletion {
if let customPresenting = viewController as? CustomPreviewPresenting {
customPresenting.presentFromPreview(presenter: delegate)
} else {
delegate.show(viewController)
}
}
}
}
}

View File

@ -1,8 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19115.2" 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="18093"/>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19107.4"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -38,7 +39,7 @@
</constraints>
</imageView>
<stackView opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="751" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="gIY-Wp-RSk">
<rect key="frame" x="58" y="0.0" width="277" height="169.5"/>
<rect key="frame" x="58" y="0.0" width="277" height="173.5"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="3Sm-P0-ySf">
<rect key="frame" x="0.0" y="0.0" width="277" height="20.5"/>
@ -132,13 +133,19 @@
<rect key="frame" x="0.0" y="169.5" width="277" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="1" verticalCompressionResistancePriority="1" text="" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="oFl-rC-EEN">
<rect key="frame" x="0.0" y="173.5" width="277" height="0.0"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" distribution="equalSpacing" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="oie-wK-IpU">
<rect key="frame" x="0.0" y="54" width="50" height="22"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="bubble.left.and.bubble.right" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="KdQ-Zn-IhD">
<rect key="frame" x="0.0" y="1" width="25.5" height="21.5"/>
<rect key="frame" x="0.0" y="0.0" width="25.5" height="21.5"/>
<color key="tintColor" systemColor="secondaryLabelColor"/>
<accessibility key="accessibilityConfiguration" label="Is a reply"/>
<constraints>
@ -218,7 +225,7 @@
<constraint firstItem="gIY-Wp-RSk" firstAttribute="leading" secondItem="QMP-j2-HLn" secondAttribute="trailing" constant="8" id="0Tm-v7-Ts4"/>
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="8" id="2Ao-Gj-fY3"/>
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="trailing" secondItem="ve3-Y1-NQH" secondAttribute="trailingMargin" id="3l0-tE-Ak1"/>
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="top" secondItem="gIY-Wp-RSk" secondAttribute="bottom" id="4KL-a3-qyf"/>
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="top" secondItem="gIY-Wp-RSk" secondAttribute="bottom" constant="-4" id="4KL-a3-qyf"/>
<constraint firstItem="oie-wK-IpU" firstAttribute="top" secondItem="QMP-j2-HLn" secondAttribute="bottom" constant="4" id="7Mp-WS-FhY"/>
<constraint firstItem="TUP-Nz-5Yh" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="oie-wK-IpU" secondAttribute="bottom" id="7Xp-Sa-Rfk"/>
<constraint firstItem="QMP-j2-HLn" firstAttribute="top" secondItem="ve3-Y1-NQH" secondAttribute="top" id="PC4-Bi-QXm"/>
@ -276,7 +283,7 @@
<image name="ellipsis" catalog="system" width="128" height="37"/>
<image name="globe" catalog="system" width="128" height="121"/>
<image name="pin.fill" catalog="system" width="119" height="128"/>
<image name="repeat" catalog="system" width="128" height="99"/>
<image name="repeat" catalog="system" width="128" height="98"/>
<image name="star.fill" catalog="system" width="128" height="116"/>
<systemColor name="labelColor">
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>

View File

@ -55,6 +55,8 @@ class VisualEffectImageButton: UIControl {
addInteraction(UIContextMenuInteraction(delegate: self))
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(onTap)))
isAccessibilityElement = true
}
@objc private func onTap() {