Compare commits

...

36 Commits

Author SHA1 Message Date
Shadowfacts 5e7a1e5974 Bump build number and update changelog 2022-07-09 12:05:17 -04:00
Shadowfacts 9b3cc61dcb Update WebURL to version with IDNA support
Closes #163
2022-07-09 11:45:27 -04:00
Shadowfacts 0c37b99a68 i don't even remember 2022-07-09 11:26:37 -04:00
Shadowfacts f96d1d780c Enable data detectors on main status text view
Tapping detected items doesn't work because it conflicts with our tap
gesture recognizer, but long pressing does
2022-07-09 11:25:23 -04:00
Shadowfacts 5a5364ad3b Use iOS 16 API for disabling compose attachment list scrolling 2022-07-09 11:02:01 -04:00
Shadowfacts 5b70c713b2 Two column navigation on iPad 2022-07-06 17:47:40 -04:00
Shadowfacts efb96eddf3 Fix compiling for Catalyst 2022-07-02 11:33:15 -07:00
Shadowfacts 5cb25c8c1f Move trending hashtags/links to Explore tab on iPad 2022-06-30 19:53:40 -07:00
Shadowfacts 700cc2c67c temp env var 2022-06-30 19:24:49 -07:00
Shadowfacts a9e0bffe5f Bump deployment target to iOS 15 2022-06-30 19:04:08 -07:00
Shadowfacts 512e0e9053 Fix passing invalid points to CoreGraphics when building trend history graph 2022-06-30 18:15:13 -07:00
Shadowfacts b842389449 Convert trending hashtags to collection view 2022-06-30 18:15:13 -07:00
Shadowfacts cc10a13785 TextKit 2, baby 2022-06-29 00:12:45 -07:00
Shadowfacts f9c3ad5921 Bring back interactive keyboard dismissal on compose screen 2022-06-28 17:30:04 -07:00
Shadowfacts 0960699699 Fix building for iOS 14 2022-06-28 17:29:46 -07:00
Shadowfacts c6e06fe9f3 Use SwiftUI for sheet presentation detents on iOS 16 2022-06-28 17:29:46 -07:00
Shadowfacts 10f6a68065 Use new-style self-sizing cells on iOS 16 2022-06-28 17:29:46 -07:00
Shadowfacts 037b717e60 Include filename extension for attachments
Fixes posting attachments on pleroma resulting in them served as
application/octet-stream, even though we're sending the mime type as well
2022-06-28 17:29:46 -07:00
Shadowfacts 9fa352d4f8 Fix retain cycle in DiffableTimelineLikeTableViewController 2022-06-28 17:29:46 -07:00
Shadowfacts 73345bb927 Always used stacked search field in instance selector 2022-06-28 17:29:46 -07:00
Shadowfacts f5385b0a1d Use context menu for filter/sort on profile directory 2022-06-28 17:29:46 -07:00
Shadowfacts 46fbbdc99a Always use stacked search bar placement on iPadOS 16 2022-06-10 23:44:52 -04:00
Shadowfacts 6ef8c92d09 Update to recommended Xcode settings 2022-06-10 23:44:52 -04:00
Shadowfacts 08b7cf013b Use browser-style navigation bars on iPad 2022-06-10 23:44:52 -04:00
Shadowfacts f702df2f15 Add context menu action for deleting draft so it's accessible by cursor 2022-06-10 23:44:52 -04:00
Shadowfacts 92efee6f46 Fix crash when loading older/newer notifications on Pixelfed
Damn Pixelfed returning nonsensical pagination links

Closes #166
2022-06-10 23:44:52 -04:00
Shadowfacts facf039f97 Live text in gallery view 2022-06-10 23:44:52 -04:00
Shadowfacts d7f35cd1e4 Bring back interactive keyboard dismissal on Compose screen 2022-06-10 23:44:52 -04:00
Shadowfacts 332637e0d9 Add edit menu actions 2022-06-10 23:44:52 -04:00
Shadowfacts 6d6fd3d49d Maybe fix crash in sceneDidEnterBackground 2022-06-10 23:44:52 -04:00
Shadowfacts b4675a97c7 Add missing awaits due to changed overload resolution 2022-06-10 23:44:52 -04:00
Shadowfacts 02e3417c27 Full size attachment previews on Compose screen (iOS 16)
Closes #110
2022-06-10 23:44:44 -04:00
Shadowfacts f5ac2616ad Disable unnecessary UIAppearance hacks on iOS 16 2022-06-07 09:42:33 -04:00
Shadowfacts 01bb37b0f6 Fix warning 2022-06-06 23:58:43 -04:00
Shadowfacts a4d43889ce Fix crash when opening conversations in new windows 2022-06-06 23:00:57 -04:00
Shadowfacts 4991da1622 Add favorite/reblog menu actions on iOS 16 2022-06-06 22:58:14 -04:00
73 changed files with 1860 additions and 792 deletions

View File

@ -1,5 +1,32 @@
# Changelog
## 2022.1 (33)
Features/Improvements:
- Show notifications when subscribed to other people's posts
- Use context menu for filter/sort in Profile Directory
- Enable data detectors (flight numbers, addresses, shippment numbers, phone numbers, currency (iOS 16), and physical units (iOS 16)) for the main status in Conversation
- iPadOS: Two column navigation
- In potrait orientation with the sidebar hidden, or in landscape, Tusker uses two column navigation on iPad
- Selecting something will open a second column in which navigation takes place
- The second column can be closed from its top level
- You can drill down farther inside the second column
- Selecting a different item in the first column replaces the second column
- iPadOS: Move Trending Hashtags/Links to Explore screen (formerly Search)
- Trending Statuses and Profile Directory will also be moved in a future version
- iPadOS: Add context menu action for deleting drafts
- iOS 16: Add Live Text to images in the gallery view
- iOS 16: Show favorite/reblog context menu actions
- iOS 16: Show full size previews when long-pressing attachments on the Compose screen
- iOS 16: Show formatting actions in edit menu on Compose screen
Bugfixes:
- Fix attachments on Pleroma not being served as the correct content type
- Fix not being able to open some hashtags with non-ASCII characters
- Fix crash when leaving the app shortly after opening it
- Fix crash when loading notifications on Pixelfed
- Fix crash due to retain cycle when changing preferences
- iPadOS: Fix crash when opening a conversation in a new window
## 2022.1 (31)
Bugfixes:
- Fix not being able to post attachments with descriptions

View File

@ -16,7 +16,7 @@ let package = Package(
],
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(url: "https://github.com/karwa/swift-url.git", from: "0.3.1"),
.package(url: "https://github.com/karwa/swift-url.git", branch: "main"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.

View File

@ -8,7 +8,7 @@
import Foundation
public enum DirectoryOrder: String {
public enum DirectoryOrder: String, CaseIterable {
case active
case new
}

View File

@ -39,3 +39,9 @@ extension Emoji: CustomDebugStringConvertible {
return ":\(shortcode):"
}
}
extension Emoji: Equatable {
public static func ==(lhs: Emoji, rhs: Emoji) -> Bool {
return lhs.shortcode == rhs.shortcode && lhs.url == rhs.url
}
}

View File

@ -12,13 +12,13 @@ import WebURLFoundationExtras
public class Hashtag: Codable {
public let name: String
public let url: URL
public let url: WebURL
/// Only present when returned from the trending hashtags endpoint
public let history: [History]?
public init(name: String, url: URL) {
self.name = name
self.url = url
self.url = WebURL(url)!
self.history = nil
}
@ -26,24 +26,14 @@ public class Hashtag: Codable {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name)
// pixelfed (possibly others) don't fully escape special characters in the hashtag url
do {
let webURL = try container.decode(WebURL.self, forKey: .url)
if let url = URL(webURL) {
self.url = url
} else {
let s = try? container.decode(String.self, forKey: .url)
throw DecodingError.dataCorruptedError(forKey: .url, in: container, debugDescription: "unable to convert WebURL \(s?.debugDescription ?? "nil") to URL")
}
} catch {
self.url = try container.decode(URL.self, forKey: .url)
}
self.url = try container.decode(WebURL.self, forKey: .url)
self.history = try container.decodeIfPresent([History].self, forKey: .history)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
try container.encode(url.absoluteString, forKey: .url)
try container.encode(url, forKey: .url)
try container.encodeIfPresent(history, forKey: .history)
}

View File

@ -8,10 +8,9 @@
import Foundation
import WebURL
import WebURLFoundationExtras
public class Mention: Codable {
public let url: URL
public let url: WebURL
public let username: String
public let acct: String
/// The instance-local ID of the user being mentioned.
@ -22,17 +21,7 @@ public class Mention: Codable {
self.username = try container.decode(String.self, forKey: .username)
self.acct = try container.decode(String.self, forKey: .acct)
self.id = try container.decode(String.self, forKey: .id)
do {
let webURL = try container.decode(WebURL.self, forKey: .url)
if let url = URL(webURL) {
self.url = url
} else {
let s = try? container.decode(String.self, forKey: .url)
throw DecodingError.dataCorruptedError(forKey: .url, in: container, debugDescription: "unable to convert WebURL \(s?.debugDescription ?? "nil") to URL")
}
} catch {
self.url = try container.decode(URL.self, forKey: .url)
}
self.url = try container.decode(WebURL.self, forKey: .url)
}
private enum CodingKeys: String, CodingKey {

View File

@ -12,9 +12,14 @@ import WebURLFoundationExtras
class URLTests: XCTestCase {
func testDecodeURL() {
print(WebURL(URL(string: "https://xn--baw-joa.social/@unituebingen")!))
let url = WebURL("https://xn--baw-joa.social/@unituebingen")
print(url)
XCTAssertNotNil(WebURL(URL(string: "https://xn--baw-joa.social/@unituebingen")!))
XCTAssertNotNil(WebURL("https://xn--baw-joa.social/@unituebingen"))
XCTAssertNotNil(URLComponents(string: "https://xn--baw-joa.social/test/é"))
XCTAssertNotNil(WebURL("https://xn--baw-joa.social/test/é"))
if #available(iOS 16.0, *) {
XCTAssertNotNil(try? URL.ParseStrategy().parse("https://xn--baw-joa.social/test/é"))
XCTAssertNotNil(try? URL.ParseStrategy().parse("http://見.香港/热狗/🌭"))
}
}
}

View File

@ -18,10 +18,7 @@
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; };
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.swift */; };
D600613E25D07E170067FAD6 /* ProfileDirectoryFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D600613D25D07E170067FAD6 /* ProfileDirectoryFilterView.swift */; };
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; };
D6093FB025BE0B01004811E6 /* TrendingHashtagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FAE25BE0B01004811E6 /* TrendingHashtagTableViewCell.swift */; };
D6093FB125BE0B01004811E6 /* TrendingHashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6093FAF25BE0B01004811E6 /* TrendingHashtagTableViewCell.xib */; };
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; };
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; };
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */; };
@ -88,6 +85,7 @@
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */; };
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */; };
D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */; };
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63D8DF32850FE7A008D95E1 /* ViewTags.swift */; };
D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */; };
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */; };
D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */ = {isa = PBXBuildFile; fileRef = D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */; };
@ -221,13 +219,11 @@
D6AEBB4523216AF800E5038B /* FollowAccountActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB4423216AF800E5038B /* FollowAccountActivity.swift */; };
D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB4723216B1D00E5038B /* AccountActivity.swift */; };
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AEBB4923216F0400E5038B /* UnfollowAccountActivity.swift */; };
D6B0539F23BD2BA300A066FA /* SheetController in Frameworks */ = {isa = PBXBuildFile; productRef = D6B0539E23BD2BA300A066FA /* SheetController */; };
D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A123BD2C0600A066FA /* AssetPickerViewController.swift */; };
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A323BD2C8100A066FA /* AssetCollectionsListViewController.swift */; };
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A523BD2D0C00A066FA /* AssetCollectionViewController.swift */; };
D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */; };
D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */; };
D6B053AE23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */; };
D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */; };
D6B22A0F2560D52D004D82EF /* TabbedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */; };
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */; };
@ -285,13 +281,17 @@
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 */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D6E343B4265AAD6B00C4AA01 /* OpenInTusker.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */; platformFilter = ios; 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 */; };
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */; };
D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = D6E57FA525C26FAB00341037 /* Localizable.stringsdict */; };
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E77D08286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift */; };
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */; };
D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */; };
D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */; };
D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E9CDA7281A427800BBC98E /* PostService.swift */; };
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */; };
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; };
@ -330,15 +330,15 @@
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
D6E3438F2659849800C4AA01 /* Embed App Extensions */ = {
D6E3438F2659849800C4AA01 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
D6E343B4265AAD6B00C4AA01 /* OpenInTusker.appex in Embed App Extensions */,
D6E343B4265AAD6B00C4AA01 /* OpenInTusker.appex in Embed Foundation Extensions */,
);
name = "Embed App Extensions";
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
D6F953E52125197500CF0F2B /* Embed Frameworks */ = {
@ -365,10 +365,7 @@
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>"; };
04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = "<group>"; };
D600613D25D07E170067FAD6 /* ProfileDirectoryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDirectoryFilterView.swift; sourceTree = "<group>"; };
D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagSearchResultsViewController.swift; sourceTree = "<group>"; };
D6093FAE25BE0B01004811E6 /* TrendingHashtagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagTableViewCell.swift; sourceTree = "<group>"; };
D6093FAF25BE0B01004811E6 /* TrendingHashtagTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TrendingHashtagTableViewCell.xib; sourceTree = "<group>"; };
D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendHistoryView.swift; sourceTree = "<group>"; };
D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseStatusTableViewCell.swift; sourceTree = "<group>"; };
D60E2F232442372B005F8713 /* StatusMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMO.swift; sourceTree = "<group>"; };
@ -434,6 +431,7 @@
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesNavigationController.swift; sourceTree = "<group>"; };
D6370B9B24421FF30092A7FF /* Tusker.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Tusker.xcdatamodel; sourceTree = "<group>"; };
D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusesViewController.swift; sourceTree = "<group>"; };
D63D8DF32850FE7A008D95E1 /* ViewTags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewTags.swift; sourceTree = "<group>"; };
D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachment.swift; sourceTree = "<group>"; };
D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectImageButton.swift; sourceTree = "<group>"; };
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = DomainBlocks.plist; sourceTree = "<group>"; };
@ -572,7 +570,6 @@
D6B053A523BD2D0C00A066FA /* AssetCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCollectionViewController.swift; sourceTree = "<group>"; };
D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCollectionViewCell.swift; sourceTree = "<group>"; };
D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AssetCollectionViewCell.xib; sourceTree = "<group>"; };
D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerSheetContainerViewController.swift; sourceTree = "<group>"; };
D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OppositeCollapseKeywordsView.swift; sourceTree = "<group>"; };
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbedPageViewController.swift; sourceTree = "<group>"; };
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGrayscalifier.swift; sourceTree = "<group>"; };
@ -646,6 +643,10 @@
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiImageView.swift; sourceTree = "<group>"; };
D6E4885C24A2890C0011C13E /* Tusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tusker.entitlements; sourceTree = "<group>"; };
D6E57FA425C26FAB00341037 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
D6E77D08286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagCollectionViewCell.swift; sourceTree = "<group>"; };
D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkCardCollectionViewCell.swift; sourceTree = "<group>"; };
D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TrendingLinkCardCollectionViewCell.xib; sourceTree = "<group>"; };
D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitNavigationController.swift; sourceTree = "<group>"; };
D6E9CDA7281A427800BBC98E /* PostService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostService.swift; sourceTree = "<group>"; };
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextField.swift; sourceTree = "<group>"; };
D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = "<group>"; };
@ -665,7 +666,6 @@
buildActionMask = 2147483647;
files = (
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
D6B0539F23BD2BA300A066FA /* SheetController in Frameworks */,
D69CCBBF249E6EFD000AF167 /* CrashReporter in Frameworks */,
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
@ -714,8 +714,7 @@
children = (
D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */,
D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */,
D6093FAE25BE0B01004811E6 /* TrendingHashtagTableViewCell.swift */,
D6093FAF25BE0B01004811E6 /* TrendingHashtagTableViewCell.xib */,
D6E77D08286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift */,
);
path = "Hashtag Cell";
sourceTree = "<group>";
@ -801,10 +800,11 @@
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */,
D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */,
D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */,
D6E77D0A286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift */,
D6E77D0C286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib */,
D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */,
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */,
D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */,
D600613D25D07E170067FAD6 /* ProfileDirectoryFilterView.swift */,
);
path = Explore;
sourceTree = "<group>";
@ -1240,7 +1240,6 @@
D6B053A523BD2D0C00A066FA /* AssetCollectionViewController.swift */,
D626493E23C101C500612E6E /* AlbumAssetCollectionViewController.swift */,
D64BC18523C1253A000D0238 /* AssetPreviewViewController.swift */,
D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */,
);
path = "Asset Picker";
sourceTree = "<group>";
@ -1301,6 +1300,7 @@
D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */,
D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */,
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */,
D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */,
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */,
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */,
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */,
@ -1372,6 +1372,7 @@
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
D62E9988279DB2D100C26176 /* InstanceFeatures.swift */,
D63D8DF32850FE7A008D95E1 /* ViewTags.swift */,
D6D4DDD6212518A200E1C4BB /* Assets.xcassets */,
D6AEBB3F2321640F00E5038B /* Activities */,
D6F1F84E2193B9BE00F5FE67 /* Caching */,
@ -1491,7 +1492,7 @@
D6D4DDCA212518A000E1C4BB /* Resources */,
D6F953E52125197500CF0F2B /* Embed Frameworks */,
D65F612C23AE957600F3CFD3 /* Embed debug-only frameworks */,
D6E3438F2659849800C4AA01 /* Embed App Extensions */,
D6E3438F2659849800C4AA01 /* Embed Foundation Extensions */,
D6F1F9E127B0677000CB7D88 /* ShellScript */,
);
buildRules = (
@ -1501,7 +1502,6 @@
);
name = Tusker;
packageProductDependencies = (
D6B0539E23BD2BA300A066FA /* SheetController */,
D69CCBBE249E6EFD000AF167 /* CrashReporter */,
D60CFFDA24A290BA00D00083 /* SwiftSoup */,
D6676CA427A8D0020052936B /* WebURLFoundationExtras */,
@ -1573,7 +1573,7 @@
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1250;
LastUpgradeCheck = 1250;
LastUpgradeCheck = 1400;
ORGANIZATIONNAME = Shadowfacts;
TargetAttributes = {
D6D4DDCB212518A000E1C4BB = {
@ -1611,7 +1611,6 @@
);
mainGroup = D6D4DDC3212518A000E1C4BB;
packageReferences = (
D6B0539D23BD2BA300A066FA /* XCRemoteSwiftPackageReference "SheetController" */,
D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */,
D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */,
@ -1634,6 +1633,7 @@
buildActionMask = 2147483647;
files = (
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */,
D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */,
D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */,
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */,
D626493923C0FD0000612E6E /* AllPhotosTableViewCell.xib in Resources */,
@ -1650,7 +1650,6 @@
D663625D2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib in Resources */,
D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */,
D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */,
D6093FB125BE0B01004811E6 /* TrendingHashtagTableViewCell.xib in Resources */,
D627FF79217E950100CC0648 /* DraftsTableViewController.xib in Resources */,
D626493D23C1000300612E6E /* AlbumTableViewCell.xib in Resources */,
D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */,
@ -1741,7 +1740,6 @@
D626493523BD94CE00612E6E /* CompositionAttachmentData.swift in Sources */,
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */,
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */,
D600613E25D07E170067FAD6 /* ProfileDirectoryFilterView.swift in Sources */,
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */,
@ -1809,6 +1807,7 @@
D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */,
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */,
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */,
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */,
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */,
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */,
@ -1821,6 +1820,7 @@
D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
D6C143E025354E34007DC240 /* EmojiPickerCollectionViewController.swift in Sources */,
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
@ -1853,7 +1853,6 @@
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */,
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */,
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */,
D6093FB025BE0B01004811E6 /* TrendingHashtagTableViewCell.swift in Sources */,
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */,
D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */,
D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */,
@ -1884,7 +1883,6 @@
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */,
D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */,
D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */,
D6B053AE23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift in Sources */,
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */,
D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */,
D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */,
@ -1925,6 +1923,7 @@
D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */,
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */,
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */,
D670F8B62537DC890046588A /* EmojiPickerWrapper.swift in Sources */,
D6B81F442560390300F6E31D /* MenuController.swift in Sources */,
@ -1968,6 +1967,7 @@
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */,
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */,
D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */,
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */,
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */,
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
@ -2198,11 +2198,10 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31;
CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2229,11 +2228,10 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31;
CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -2339,7 +2337,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31;
CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2366,7 +2364,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 31;
CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2461,14 +2459,6 @@
minimumVersion = 1.8.0;
};
};
D6B0539D23BD2BA300A066FA /* XCRemoteSwiftPackageReference "SheetController" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://git.shadowfacts.net/shadowfacts/SheetController.git";
requirement = {
branch = master;
kind = branch;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@ -2491,11 +2481,6 @@
package = D69CCBBD249E6EFD000AF167 /* XCRemoteSwiftPackageReference "plcrashreporter" */;
productName = CrashReporter;
};
D6B0539E23BD2BA300A066FA /* SheetController */ = {
isa = XCSwiftPackageProductDependency;
package = D6B0539D23BD2BA300A066FA /* XCRemoteSwiftPackageReference "SheetController" */;
productName = SheetController;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCVersionGroup section */

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1250"
LastUpgradeVersion = "1400"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1250"
LastUpgradeVersion = "1400"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
@ -99,6 +99,11 @@
value = "1"
isEnabled = "NO">
</EnvironmentVariable>
<EnvironmentVariable
key = "CG_NUMERICS_SHOW_BACKTRACE"
value = ""
isEnabled = "NO">
</EnvironmentVariable>
<EnvironmentVariable
key = "DEBUG_BLUR_HASH"
value = "1"

View File

@ -41,20 +41,14 @@ class ImageCache {
let wrappedCompletion: ((Data?, UIImage?) -> Void)?
if let completion = completion {
wrappedCompletion = { (data, image) in
if #available(iOS 15.0, *) {
if !loadOriginal,
let size = self.desiredPixelSize {
image?.prepareThumbnail(of: size, completionHandler: {
completion(data, $0)
})
} else {
image?.prepareForDisplay {
completion(data, $0)
}
}
if !loadOriginal,
let size = self.desiredPixelSize {
image?.prepareThumbnail(of: size, completionHandler: {
completion(data, $0)
})
} else {
self.backgroundQueue.async {
completion(data, image)
image?.prepareForDisplay {
completion(data, $0)
}
}
}

View File

@ -48,7 +48,7 @@ class MastodonController: ObservableObject {
@Published private(set) var instanceFeatures = InstanceFeatures()
private(set) var customEmojis: [Emoji]?
private var pendingOwnInstanceRequestCallbacks = [(Instance) -> Void]()
private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]()
private var ownInstanceRequest: URLSessionTask?
var loggedIn: Bool {
@ -159,15 +159,28 @@ class MastodonController: ObservableObject {
}
func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
getOwnInstanceInternal(retryAttempt: 0, completion: completion)
getOwnInstanceInternal(retryAttempt: 0) {
if case let .success(instance) = $0 {
completion?(instance)
}
}
}
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Instance) -> Void)?) {
@MainActor
func getOwnInstance() async throws -> Instance {
return try await withCheckedThrowingContinuation({ continuation in
getOwnInstanceInternal(retryAttempt: 0) { result in
continuation.resume(with: result)
}
})
}
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<Instance, Client.Error>) -> Void)?) {
// this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks
assert(Thread.isMainThread)
if let instance = self.instance {
completion?(instance)
completion?(.success(instance))
} else {
if let completion = completion {
pendingOwnInstanceRequestCallbacks.append(completion)
@ -177,7 +190,7 @@ class MastodonController: ObservableObject {
let request = Client.getInstance()
ownInstanceRequest = run(request) { (response) in
switch response {
case .failure(_):
case .failure(let error):
let delay: DispatchTimeInterval
switch retryAttempt {
case 0:
@ -190,6 +203,10 @@ class MastodonController: ObservableObject {
delay = .seconds(60)
default:
// if we've failed four times, just give up :/
for completion in self.pendingOwnInstanceRequestCallbacks {
completion(.failure(error))
}
self.pendingOwnInstanceRequestCallbacks = []
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
@ -204,7 +221,7 @@ class MastodonController: ObservableObject {
self.instanceFeatures.update(instance: instance, nodeInfo: self.nodeInfo)
for completion in self.pendingOwnInstanceRequestCallbacks {
completion(instance)
completion(.success(instance))
}
self.pendingOwnInstanceRequestCallbacks = []
}

View File

@ -22,7 +22,7 @@ struct MenuController {
let data: Any
if case let .tab(tab) = item {
data = tab.rawValue
} else if case .search = item {
} else if case .explore = item {
data = "search"
} else if case .bookmarks = item {
data = "bookmarks"
@ -42,7 +42,7 @@ struct MenuController {
static let sidebarItemKeyCommands: [UIKeyCommand] = [
sidebarCommand(item: .tab(.timelines), command: "1"),
sidebarCommand(item: .tab(.notifications), command: "2"),
sidebarCommand(item: .search, command: "3"),
sidebarCommand(item: .explore, command: "3"),
sidebarCommand(item: .bookmarks, command: "4"),
sidebarCommand(item: .tab(.myProfile), command: "5"),
]

View File

@ -9,6 +9,7 @@
import Foundation
import CoreData
import Pachyderm
import WebURLFoundationExtras
@objc(SavedHashtag)
public final class SavedHashtag: NSManagedObject {
@ -32,6 +33,6 @@ extension SavedHashtag {
convenience init(hashtag: Hashtag, context: NSManagedObjectContext) {
self.init(context: context)
self.name = hashtag.name
self.url = hashtag.url
self.url = URL(hashtag.url)!
}
}

View File

@ -34,6 +34,10 @@ struct InstanceFeatures {
instanceType != .pixelfed
}
var trends: Bool {
instanceType == .mastodon
}
var trendingStatusesAndLinks: Bool {
instanceType == .mastodon && version != nil && version! >= Version(3, 5, 0)
}

View File

@ -58,7 +58,7 @@ public struct LazilyDecoding<Enclosing, Value: Codable> {
}
extension LazilyDecoding {
init<T: Codable>(arrayFrom keyPath: ReferenceWritableKeyPath<Enclosing, Data?>) where Value == [T] {
init<T>(arrayFrom keyPath: ReferenceWritableKeyPath<Enclosing, Data?>) where Value == [T] {
self.init(from: keyPath, fallback: [])
}
}

View File

@ -123,7 +123,10 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
rootVC.sceneDidEnterBackground()
}
if let context = scene.session.mastodonController?.persistentContainer.viewContext {
if let context = scene.session.mastodonController?.persistentContainer.viewContext,
// if the user quickly opens and then closes the app, this may race with loading the persistent store, so in that event we skip cleanup/save
let psc = context.persistentStoreCoordinator,
!psc.persistentStores.isEmpty {
var minDate = Date()
minDate.addTimeInterval(-7 * 24 * 60 * 60)

View File

@ -48,13 +48,13 @@ enum CompositionAttachmentData {
}
}
func getData(completion: @escaping (Result<(Data, String), Error>) -> Void) {
func getData(completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
switch self {
case let .image(image):
// Export as JPEG instead of PNG, otherweise photos straight from the camera are too large
// for Mastodon in its default configuration (max of 10MB).
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future.
completion(.success((image.jpegData(compressionQuality: 0.8)!, "image/jpeg")))
completion(.success((image.jpegData(compressionQuality: 0.8)!, .jpeg)))
case let .asset(asset):
if asset.mediaType == .image {
let options = PHImageRequestOptions()
@ -68,19 +68,19 @@ enum CompositionAttachmentData {
return
}
let mimeType: String
let utType: UTType
if dataUTI == "public.heic" {
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
let image = CIImage(data: data)!
let context = CIContext()
let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!
data = context.jpegRepresentation(of: image, colorSpace: colorSpace, options: [:])!
mimeType = "image/jpeg"
utType = .jpeg
} else {
mimeType = UTType(dataUTI)!.preferredMIMEType!
utType = UTType(dataUTI)!
}
completion(.success((data, mimeType)))
completion(.success((data, utType)))
}
} else if asset.mediaType == .video {
let options = PHVideoRequestOptions()
@ -109,11 +109,11 @@ enum CompositionAttachmentData {
case let .drawing(drawing):
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
completion(.success((image.pngData()!, "image/png")))
completion(.success((image.pngData()!, .png)))
}
}
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, String), Error>) -> Void) {
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
session.outputFileType = .mp4
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
session.exportAsynchronously {
@ -123,7 +123,7 @@ enum CompositionAttachmentData {
}
do {
let data = try Data(contentsOf: session.outputURL!)
completion(.success((data, "video/mp4")))
completion(.success((data, .mpeg4Movie)))
} catch {
completion(.failure(.videoExport(error)))
}

View File

@ -10,7 +10,7 @@ import UIKit
import Pachyderm
enum StatusFormat: CaseIterable {
case italics, bold, strikethrough, code
case bold, italics, strikethrough, code
var insertionResult: FormatInsertionResult? {
switch Preferences.shared.statusContentType {

View File

@ -1,63 +0,0 @@
//
// AssetPickerSheetContainerViewController.swift
// Tusker
//
// Created by Shadowfacts on 1/1/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import SheetController
import Photos
class AssetPickerSheetContainerViewController: SheetContainerViewController {
let assetPicker = AssetPickerViewController()
init() {
super.init(content: assetPicker)
assetPicker.view.translatesAutoresizingMaskIntoConstraints = false
assetPicker.view.layer.masksToBounds = true
delegate = self
assetPicker.delegate = self
detents = [.bottom, .middle, .top]
overrideUserInterfaceStyle = .dark
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
assetPicker.view.layer.cornerRadius = view.bounds.width * 0.02
// don't round bottom corners, since they'll always be cut off by the device
assetPicker.view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
super.viewDidLoad()
}
}
extension AssetPickerSheetContainerViewController: SheetContainerViewControllerDelegate {
func sheetContainerContentScrollView(_ sheetContainer: SheetContainerViewController) -> UIScrollView? {
if let vc = assetPicker.visibleViewController as? UITableViewController {
return vc.tableView
} else if let vc = assetPicker.visibleViewController as? UICollectionViewController {
return vc.collectionView
}
return nil
}
func sheetContainer(_ sheetContainer: SheetContainerViewController, topContentOffsetForScrollView scrollView: UIScrollView) -> CGFloat {
return assetPicker.navigationBar.bounds.height
}
}
extension AssetPickerSheetContainerViewController: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
contentScrollViewChanged()
// viewController.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(donePressed))
}
}

View File

@ -37,15 +37,11 @@ struct ComposeAttachmentRow: View {
}
}
if #available(iOS 15.0, *) {
Button(action: self.removeAttachment) {
Label("Delete", systemImage: "trash")
}.foregroundStyle(.red)
} else {
Button(action: self.removeAttachment) {
Label("Delete", systemImage: "trash")
}
}
Button(action: self.removeAttachment) {
Label("Delete", systemImage: "trash")
}.foregroundStyle(.red)
} previewIfAvailable: {
ComposeAttachmentImage(attachment: attachment, fullSize: true)
}
switch mode {
@ -151,6 +147,18 @@ extension ComposeAttachmentRow {
}
}
private extension View {
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func contextMenu<M: View, P: View>(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View {
if #available(iOS 16.0, *) {
self.contextMenu(menuItems: menuItems, preview: preview)
} else {
self.contextMenu(menuItems: menuItems)
}
}
}
//struct ComposeAttachmentRow_Previews: PreviewProvider {
// static var previews: some View {
// ComposeAttachmentRow()

View File

@ -50,7 +50,7 @@ struct ComposeAttachmentsList: View {
.disabled(!canAddAttachment)
.foregroundColor(.blue)
.frame(height: cellHeight / 2)
.popover(isPresented: $isShowingAssetPickerPopover, content: self.assetPickerPopover)
.sheetOrPopover(isPresented: $isShowingAssetPickerPopover, content: self.assetPickerPopover)
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
Button(action: self.createDrawing) {
@ -70,7 +70,9 @@ struct ComposeAttachmentsList: View {
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
}
.listStyle(PlainListStyle())
// todo: scrollDisabled doesn't remove the need for manually calculating the frame height
.frame(height: totalListHeight)
.scrollDisabledIfAvailable(totalHeight: totalListHeight)
.onAppear(perform: self.didAppear)
.onReceive(draft.$attachments, perform: self.attachmentsChanged)
}
@ -110,10 +112,14 @@ struct ComposeAttachmentsList: View {
}
private func didAppear() {
let proxy = UITableView.appearance(whenContainedInInstancesOf: [ComposeHostingController.self])
// enable drag and drop to reorder on iPhone
proxy.dragInteractionEnabled = true
proxy.isScrollEnabled = false
if #available(iOS 16.0, *) {
// these appearance proxy hacks are no longer necessary
} else {
let proxy = UITableView.appearance(whenContainedInInstancesOf: [ComposeHostingController.self])
// enable drag and drop to reorder on iPhone
proxy.dragInteractionEnabled = true
proxy.isScrollEnabled = false
}
}
private func attachmentsChanged(attachments: [CompositionAttachment]) {
@ -130,14 +136,21 @@ struct ComposeAttachmentsList: View {
private func assetPickerPopover() -> some View {
ComposeAssetPicker(draft: draft, delegate: uiState.delegate?.assetPickerDelegate)
.onDisappear {
// on iPadOS 16, this is necessary to dismiss the popover when collapsing from regular -> compact size class
// otherwise, the popover isn't visible but it's still "presented", so the sheet can't be shown
self.isShowingAssetPickerPopover = false
}
// on iPadOS 16, this is necessary to show the dark color in the popover arrow
.background(Color(.systemBackground))
.environment(\.colorScheme, .dark)
.edgesIgnoringSafeArea(.bottom)
.withSheetDetentsIfAvailable()
}
private func addAttachment() {
if horizontalSizeClass == .regular {
if #available(iOS 16.0, *) {
isShowingAssetPickerPopover = true
} else if horizontalSizeClass == .regular {
isShowingAssetPickerPopover = true
} else {
uiState.delegate?.presentAssetPickerSheet()
@ -180,12 +193,52 @@ struct ComposeAttachmentsList: View {
}
fileprivate extension View {
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func onDragWithPreviewIfAvailable<V>(_ data: @escaping () -> NSItemProvider, preview: () -> V) -> some View where V : View {
if #available(iOS 15.0, *) {
self.onDrag(data, preview: preview)
func sheetOrPopover(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> some View) -> some View {
if #available(iOS 16.0, *) {
self.modifier(SheetOrPopover(isPresented: isPresented, view: content))
} else {
self.onDrag(data)
self.popover(isPresented: isPresented, content: content)
}
}
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func withSheetDetentsIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
} else {
self
}
}
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDisabledIfAvailable(totalHeight: CGFloat) -> some View {
if #available(iOS 16.0, *) {
self.scrollDisabled(true)
} else {
self.frame(height: totalHeight)
}
}
}
@available(iOS 16.0, *)
struct SheetOrPopover<V: View>: ViewModifier {
@Binding var isPresented: Bool
@ViewBuilder let view: () -> V
@Environment(\.horizontalSizeClass) var sizeClass
func body(content: Content) -> some View {
let _ = print("isPresented: \(isPresented)")
if sizeClass == .compact {
content.sheet(isPresented: $isPresented, content: view)
} else {
content.popover(isPresented: $isPresented, content: view)
}
}
}

View File

@ -25,8 +25,6 @@ struct ComposeAutocompleteView: View {
var body: some View {
suggestionsView
// animate changes of the scroll view items
.animation(.default)
.background(backgroundColor)
.overlay(borderColor.frame(height: 0.5), alignment: .top)
}
@ -85,8 +83,8 @@ struct ComposeAutocompleteMentionsView: View {
}
.frame(height: 30)
.padding(.vertical, 8)
.animation(.linear(duration: 0.1))
}
.animation(.linear(duration: 0.1), value: accounts)
Spacer()
}
@ -167,7 +165,7 @@ struct ComposeAutocompleteMentionsView: View {
.map(\.0)
}
private enum EitherAccount {
private enum EitherAccount: Equatable {
case pachyderm(Account)
case coreData(AccountMO)
@ -197,6 +195,10 @@ struct ComposeAutocompleteMentionsView: View {
return account.avatar
}
}
static func ==(lhs: EitherAccount, rhs: EitherAccount) -> Bool {
return lhs.id == rhs.id
}
}
}
@ -212,7 +214,7 @@ struct ComposeAutocompleteEmojisView: View {
HStack(alignment: expanded ? .top : .center, spacing: 0) {
if case let .emoji(query) = uiState.autocompleteState {
emojiList(query: query)
.animation(.default)
.animation(.default, value: expanded)
.transition(.move(edge: .bottom))
} else {
// when the autocomplete view is animating out, the autocomplete state is nil
@ -259,8 +261,8 @@ struct ComposeAutocompleteEmojisView: View {
}
.frame(height: 30)
.padding(.vertical, 8)
.animation(.linear(duration: 0.2))
}
.animation(.linear(duration: 0.2), value: emojis)
Spacer(minLength: 30)
}
@ -319,8 +321,8 @@ struct ComposeAutocompleteHashtagsView: View {
}
.frame(height: 30)
.padding(.vertical, 8)
.animation(.linear(duration: 0.1))
}
.animation(.linear(duration: 0.1), value: hashtags)
Spacer()
}

View File

@ -111,6 +111,17 @@ struct ComposeEmojiTextField: UIViewRepresentable {
self.updateAutocompleteState(textField: textField)
}
func textField(_ textField: UITextField, editMenuForCharactersIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
var actions = suggestedActions
if range.length == 0 {
actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in
self?.uiState.shouldEmojiAutocompletionBeginExpanded = true
self?.beginAutocompletingEmoji()
}))
}
return UIMenu(children: actions)
}
func beginAutocompletingEmoji() {
textField?.insertText(":")
}

View File

@ -15,9 +15,6 @@ protocol ComposeHostingControllerDelegate: AnyObject {
func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool
}
private let VISIBILITY_BAR_BUTTON_TAG = 42001
private let LOCAL_ONLY_BAR_BUTTON_TAG = 42002
class ComposeHostingController: UIHostingController<ComposeContainerView> {
weak var delegate: ComposeHostingControllerDelegate?
@ -135,12 +132,12 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
items.append(UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed)))
let visibilityItem = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil)
visibilityItem.tag = VISIBILITY_BAR_BUTTON_TAG
visibilityItem.tag = ViewTags.composeVisibilityBarButton
items.append(visibilityItem)
if mastodonController.instanceFeatures.localOnlyPosts {
let item = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil)
item.tag = LOCAL_ONLY_BAR_BUTTON_TAG
item.tag = ViewTags.composeLocalOnlyBarButton
items.append(item)
localOnlyChanged(draft.localOnly)
}
@ -243,7 +240,7 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
private func visibilityChanged(_ newVisibility: Status.Visibility) {
for toolbar in [mainToolbar, inputAccessoryToolbar] {
guard let item = toolbar?.items?.first(where: { $0.tag == VISIBILITY_BAR_BUTTON_TAG }) else {
guard let item = toolbar?.items?.first(where: { $0.tag == ViewTags.composeVisibilityBarButton }) else {
continue
}
item.image = UIImage(systemName: newVisibility.imageName)
@ -260,7 +257,7 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
private func localOnlyChanged(_ localOnly: Bool) {
for toolbar in [mainToolbar, inputAccessoryToolbar] {
guard let item = toolbar?.items?.first(where: { $0.tag == LOCAL_ONLY_BAR_BUTTON_TAG }) else {
guard let item = toolbar?.items?.first(where: { $0.tag == ViewTags.composeLocalOnlyBarButton }) else {
continue
}
if localOnly {
@ -342,24 +339,14 @@ extension ComposeHostingController: ComposeUIStateDelegate {
}
func presentAssetPickerSheet() {
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()
}
}
private func presentOldAssetPickerSheet() {
let sheetContainer = AssetPickerSheetContainerViewController()
sheetContainer.assetPicker.assetPickerDelegate = self
self.present(sheetContainer, animated: true)
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)
}
func presentComposeDrawing() {
@ -474,13 +461,3 @@ extension ComposeHostingController: ComposeDrawingViewControllerDelegate {
dismiss(animated: true)
}
}
fileprivate extension UIAction {
convenience init(title: String, subtitle: String?, image: UIImage?, state: UIAction.State, handler: @escaping UIActionHandler) {
if #available(iOS 15.0, *) {
self.init(title: title, subtitle: subtitle, image: image, identifier: nil, discoverabilityTitle: nil, attributes: [], state: state, handler: handler)
} else {
self.init(title: title, image: image, identifier: nil, discoverabilityTitle: nil, attributes: [], state: state, handler: handler)
}
}
}

View File

@ -60,7 +60,9 @@ struct ComposePollView: View {
HStack {
// use .animation(nil) on pickers so frame doesn't have a size change animation when the text changes
Picker(selection: $poll.multiple, label: Text(poll.multiple ? "Allow multiple choices" : "Single choice")) {
// this is deprecated in iOS 15, but using .animation(nil, value: poll.multiple) does not work (it still animates)
// nor does setting that on the Text rather than the Picker
Picker(selection: $poll.multiple, label: Text(poll.multiple ? "Allow multiple choice" : "Single choice")) {
Text("Allow multiple choices").tag(true)
Text("Single choice").tag(false)
}
@ -154,8 +156,7 @@ struct ComposePollOption: View {
var body: some View {
HStack(spacing: 4) {
Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, borderWidth: 2)
.animation(.default)
.animation(.default, value: poll.multiple)
textField

View File

@ -12,6 +12,7 @@ protocol ComposeUIStateDelegate: AnyObject {
var assetPickerDelegate: AssetPickerViewControllerDelegate? { get }
func dismissCompose(mode: ComposeUIState.DismissMode)
// @available(iOS, obsoleted: 16.0)
func presentAssetPickerSheet()
func presentComposeDrawing()
@ -48,7 +49,7 @@ extension ComposeUIState {
}
extension ComposeUIState {
enum AutocompleteState {
enum AutocompleteState: Equatable {
case mention(String)
case emoji(String)
case hashtag(String)

View File

@ -89,6 +89,7 @@ struct ComposeView: View {
ScrollView(.vertical) {
mainStack(outerMinY: outer.frame(in: .global).minY)
}
.scrollDismissesKeyboardInteractivelyIfAvailable()
}
if let poster = poster {
@ -118,7 +119,7 @@ struct ComposeView: View {
}
}
.transition(.move(edge: .bottom))
.animation(.default)
.animation(.default, value: uiState.autocompleteState)
}
func mainStack(outerMinY: CGFloat) -> some View {
@ -146,7 +147,6 @@ struct ComposeView: View {
if let poll = draft.poll {
ComposePollView(draft: draft, poll: poll)
.transition(.opacity.combined(with: .asymmetric(insertion: .scale(scale: 0.5, anchor: .leading), removal: .scale(scale: 0.5, anchor: .trailing))))
.animation(.default)
}
@ -250,6 +250,18 @@ struct ComposeView: View {
}
}
private extension View {
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self.scrollDismissesKeyboard(.interactively)
} else {
self
}
}
}
//struct ComposeView_Previews: PreviewProvider {
// static var previews: some View {
// ComposeView()

View File

@ -193,8 +193,42 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
self.updateAutocompleteState()
}
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
var actions = suggestedActions
if Preferences.shared.statusContentType != .plain,
let index = suggestedActions.firstIndex(where: { ($0 as? UIMenu)?.identifier.rawValue == "com.apple.menu.format" }) {
if range.length > 0 {
let formatMenu = suggestedActions[index] as! UIMenu
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
UIAction(title: fmt.accessibilityLabel, image: fmt.image) { [weak self] _ in
self?.applyFormat(fmt)
}
})
actions[index] = newFormatMenu
} else {
actions.remove(at: index)
}
}
if range.length == 0 {
actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in
self?.uiState.shouldEmojiAutocompletionBeginExpanded = true
self?.beginAutocompletingEmoji()
}))
}
return UIMenu(children: actions)
}
func beginAutocompletingEmoji() {
textView?.insertText(":")
guard let textView = textView else {
return
}
var insertSpace = false
if let text = textView.text,
textView.selectedRange.upperBound > 0 {
let characterBeforeCursorIndex = text.utf16.index(before: text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound))
insertSpace = !text[characterBeforeCursorIndex].isWhitespace
}
textView.insertText((insertSpace ? " " : "") + ":")
}
func autocomplete(with string: String) {

View File

@ -124,13 +124,8 @@ class ConversationTableViewController: EnhancedTableViewController {
}
})
if #available(iOS 15.0, *) {
visibilityBarButtonItem = UIBarButtonItem(image: ConversationTableViewController.showPostsImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed))
visibilityBarButtonItem.isSelected = showStatusesAutomatically
} else {
let initialImage = showStatusesAutomatically ? ConversationTableViewController.hidePostsImage : ConversationTableViewController.showPostsImage
visibilityBarButtonItem = UIBarButtonItem(image: initialImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed))
}
visibilityBarButtonItem = UIBarButtonItem(image: ConversationTableViewController.showPostsImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed))
visibilityBarButtonItem.isSelected = showStatusesAutomatically
navigationItem.rightBarButtonItem = visibilityBarButtonItem
// disable transparent background when scroll to top because it looks weird when items earlier in the thread load in
// (it remains transparent slightly too long, resulting in a flash of the content under the transparent bar)
@ -175,7 +170,7 @@ class ConversationTableViewController: EnhancedTableViewController {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
snapshot.appendItems([mainStatusItem], toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: false)
await dataSource.apply(snapshot, animatingDifferences: false)
loadingState = .loadedMain
@ -396,15 +391,7 @@ class ConversationTableViewController: EnhancedTableViewController {
tableView.beginUpdates()
tableView.endUpdates()
if #available(iOS 15.0, *) {
visibilityBarButtonItem.isSelected = showStatusesAutomatically
} else {
if showStatusesAutomatically {
visibilityBarButtonItem.image = ConversationTableViewController.hidePostsImage
} else {
visibilityBarButtonItem.image = ConversationTableViewController.showPostsImage
}
}
visibilityBarButtonItem.isSelected = showStatusesAutomatically
}
}

View File

@ -110,6 +110,18 @@ class DraftsTableViewController: UITableViewController {
tableView.deleteRows(at: [indexPath], with: .automatic)
}
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(actionProvider: { _ in
return UIMenu(children: [
UIAction(title: "Delete Draft", image: UIImage(systemName: "trash"), attributes: .destructive, handler: { [unowned self] _ in
DraftsManager.shared.remove(self.draft(for: indexPath))
drafts.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .automatic)
})
])
})
}
// MARK: - Interaction
@objc func cancelPressed() {

View File

@ -9,19 +9,20 @@
import UIKit
import Pachyderm
class AddSavedHashtagViewController: EnhancedTableViewController {
class AddSavedHashtagViewController: UIViewController {
weak var mastodonController: MastodonController!
var resultsController: SearchResultsViewController!
var searchController: UISearchController!
var dataSource: UITableViewDiffableDataSource<Section, Item>!
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
super.init(style: .grouped)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
@ -33,18 +34,38 @@ class AddSavedHashtagViewController: EnhancedTableViewController {
title = NSLocalizedString("Search", comment: "search screen title")
tableView.register(UINib(nibName: "TrendingHashtagTableViewCell", bundle: .main), forCellReuseIdentifier: "trendingTagCell")
tableView.rowHeight = 60 // 44 for content + 2 * 8 spacing
dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
view.backgroundColor = .systemGroupedBackground
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.headerMode = .supplementary
let layout = UICollectionViewCompositionalLayout.list(using: config)
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.delegate = self
view.addSubview(collectionView)
let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { (headerView, collectionView, indexPath) in
var config = headerView.defaultContentConfiguration()
config.text = NSLocalizedString("Trending Hashtags", comment: "trending hashtags section title")
headerView.contentConfiguration = config
}
let registration = UICollectionView.CellRegistration<TrendingHashtagCollectionViewCell, Hashtag> { cell, indexPath, hashtag in
cell.updateUI(hashtag: hashtag)
}
dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { (collectionView, indexPath, item) in
switch item {
case let .tag(hashtag):
let cell = tableView.dequeueReusableCell(withIdentifier: "trendingTagCell", for: indexPath) as! TrendingHashtagTableViewCell
cell.updateUI(hashtag: hashtag)
return cell
return collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: hashtag)
}
})
}
dataSource.supplementaryViewProvider = { (collectionView, elementKind, indexPath) in
if elementKind == UICollectionView.elementKindSectionHeader {
return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderCell, for: indexPath)
} else {
return nil
}
}
resultsController = HashtagSearchResultsViewController(mastodonController: mastodonController)
resultsController.delegate = self
resultsController.exploreNavigationController = self.navigationController!
@ -92,17 +113,6 @@ class AddSavedHashtagViewController: EnhancedTableViewController {
presentingViewController!.dismiss(animated: true)
}
// MARK: - Table View Delegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
switch dataSource.itemIdentifier(for: indexPath) {
case nil:
return
case let .tag(hashtag):
selectHashtag(hashtag)
}
}
// MARK: - Interaction
@objc func cancelButtonPressed() {
@ -115,14 +125,23 @@ extension AddSavedHashtagViewController {
enum Section {
case trendingTags
}
enum Item: Hashable {
case tag(Hashtag)
}
class DataSource: UITableViewDiffableDataSource<Section, Item> {
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return NSLocalizedString("Trending Hashtags", comment: "trending hashtags seciton title")
// class DataSource: UITableViewDiffableDataSource<Section, Item> {
// override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
// return
// }
// }
}
extension AddSavedHashtagViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
switch dataSource.itemIdentifier(for: indexPath) {
case nil:
return
case let .tag(hashtag):
selectHashtag(hashtag)
}
}
}

View File

@ -10,6 +10,7 @@ import UIKit
import Combine
import Pachyderm
import CoreData
import WebURLFoundationExtras
class ExploreViewController: UIViewController, UICollectionViewDelegate {
@ -582,7 +583,10 @@ extension ExploreViewController: UICollectionViewDragDelegate {
activity.displaysAuxiliaryScene = true
provider = NSItemProvider(object: activity)
case let .savedHashtag(hashtag):
provider = NSItemProvider(object: hashtag.url as NSURL)
guard let url = URL(hashtag.url) else {
return []
}
provider = NSItemProvider(object: url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: accountID) {
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)

View File

@ -1,134 +0,0 @@
//
// ProfileDirectoryFilterView.swift
// Tusker
//
// Created by Shadowfacts on 2/7/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class ProfileDirectoryFilterView: UICollectionReusableView {
var onFilterChanged: ((Scope, DirectoryOrder) -> Void)?
private var scope: UISegmentedControl!
private var sort: UISegmentedControl!
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
scope = UISegmentedControl(items: ["Instance", NSLocalizedString("Everywhere", comment: "everywhere profile directory scope")])
scope.selectedSegmentIndex = 0
scope.addTarget(self, action: #selector(filterChanged), for: .valueChanged)
sort = UISegmentedControl(items: [
NSLocalizedString("Active", comment: "active profile directory sort"),
NSLocalizedString("New", comment: "new profile directory sort"),
])
sort.selectedSegmentIndex = 0
sort.addTarget(self, action: #selector(filterChanged), for: .valueChanged)
let fromLabel = UILabel()
fromLabel.translatesAutoresizingMaskIntoConstraints = false
fromLabel.text = NSLocalizedString("From", comment: "profile directory scope label")
fromLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
fromLabel.textAlignment = .right
let sortLabel = UILabel()
sortLabel.translatesAutoresizingMaskIntoConstraints = false
sortLabel.text = NSLocalizedString("Sort By", comment: "profile directory sort label")
sortLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
sortLabel.textAlignment = .right
let labelContainer = UIView()
labelContainer.addSubview(sortLabel)
labelContainer.addSubview(fromLabel)
let controlStack = UIStackView(arrangedSubviews: [sort, scope])
controlStack.axis = .vertical
controlStack.spacing = 8
let blurEffect = UIBlurEffect(style: .systemChromeMaterial)
let blurView = UIVisualEffectView(effect: blurEffect)
blurView.translatesAutoresizingMaskIntoConstraints = false
addSubview(blurView)
let vibrancyEffect = UIVibrancyEffect(blurEffect: blurEffect, style: .label)
let vibrancyView = UIVisualEffectView(effect: vibrancyEffect)
vibrancyView.translatesAutoresizingMaskIntoConstraints = false
blurView.contentView.addSubview(vibrancyView)
let filterStack = UIStackView(arrangedSubviews: [
labelContainer,
controlStack,
])
filterStack.axis = .horizontal
filterStack.spacing = 8
filterStack.translatesAutoresizingMaskIntoConstraints = false
vibrancyView.contentView.addSubview(filterStack)
let separator = UIView()
separator.backgroundColor = .separator
separator.translatesAutoresizingMaskIntoConstraints = false
addSubview(separator)
NSLayoutConstraint.activate([
fromLabel.leadingAnchor.constraint(equalTo: labelContainer.leadingAnchor),
fromLabel.trailingAnchor.constraint(equalTo: labelContainer.trailingAnchor),
fromLabel.centerYAnchor.constraint(equalTo: scope.centerYAnchor),
sortLabel.leadingAnchor.constraint(equalTo: labelContainer.leadingAnchor),
sortLabel.trailingAnchor.constraint(equalTo: labelContainer.trailingAnchor),
sortLabel.centerYAnchor.constraint(equalTo: sort.centerYAnchor),
blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
blurView.topAnchor.constraint(equalTo: topAnchor),
blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
vibrancyView.leadingAnchor.constraint(equalTo: blurView.contentView.leadingAnchor),
vibrancyView.trailingAnchor.constraint(equalTo: blurView.contentView.trailingAnchor),
vibrancyView.topAnchor.constraint(equalTo: blurView.contentView.topAnchor),
vibrancyView.bottomAnchor.constraint(equalTo: blurView.contentView.bottomAnchor),
filterStack.leadingAnchor.constraint(equalToSystemSpacingAfter: vibrancyView.contentView.leadingAnchor, multiplier: 1),
vibrancyView.contentView.trailingAnchor.constraint(equalToSystemSpacingAfter: filterStack.trailingAnchor, multiplier: 1),
filterStack.topAnchor.constraint(equalToSystemSpacingBelow: vibrancyView.contentView.topAnchor, multiplier: 1),
vibrancyView.contentView.bottomAnchor.constraint(equalToSystemSpacingBelow: filterStack.bottomAnchor, multiplier: 1),
separator.leadingAnchor.constraint(equalTo: leadingAnchor),
separator.trailingAnchor.constraint(equalTo: trailingAnchor),
separator.bottomAnchor.constraint(equalTo: bottomAnchor),
separator.heightAnchor.constraint(equalToConstant: 0.5),
])
}
func updateUI(mastodonController: MastodonController) {
scope.setTitle(mastodonController.accountInfo!.instanceURL.host!, forSegmentAt: 0)
}
@objc private func filterChanged() {
let scope = Scope(rawValue: self.scope.selectedSegmentIndex)!
let order = sort.selectedSegmentIndex == 0 ? DirectoryOrder.active : .new
onFilterChanged?(scope, order)
}
}
extension ProfileDirectoryFilterView {
enum Scope: Int, Equatable {
case instance, everywhere
}
}

View File

@ -16,6 +16,9 @@ class ProfileDirectoryViewController: UIViewController {
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var scope: Scope = .everywhere
private var order: DirectoryOrder = .active
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
@ -31,14 +34,10 @@ class ProfileDirectoryViewController: UIViewController {
title = NSLocalizedString("Profile Directory", comment: "profile directory title")
let configuration = UICollectionViewCompositionalLayoutConfiguration()
configuration.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(100)),
elementKind: "filter",
alignment: .top
)
]
// todo: it would be nice if there were a better "filter" icon
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "scope"), menu: nil)
updateFilterMenu()
let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) in
let itemHeight = NSCollectionLayoutDimension.absolute(200)
let itemWidth: NSCollectionLayoutDimension
@ -60,19 +59,18 @@ class ProfileDirectoryViewController: UIViewController {
section.interGroupSpacing = 16
section.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)
return section
}, configuration: configuration)
})
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.backgroundColor = .secondarySystemBackground
collectionView.register(UINib(nibName: "FeaturedProfileCollectionViewCell", bundle: .main), forCellWithReuseIdentifier: "featuredProfileCell")
collectionView.register(ProfileDirectoryFilterView.self, forSupplementaryViewOfKind: "filter", withReuseIdentifier: "filter")
collectionView.delegate = self
collectionView.dragDelegate = self
view.addSubview(collectionView)
dataSource = createDataSource()
updateProfiles(local: true, order: .active)
updateProfiles()
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
@ -82,26 +80,44 @@ class ProfileDirectoryViewController: UIViewController {
cell.updateUI(account: account)
return cell
}
dataSource.supplementaryViewProvider = { (collectionView, elementKind, indexPath) in
guard elementKind == "filter" else {
return nil
}
let filterView = collectionView.dequeueReusableSupplementaryView(ofKind: "filter", withReuseIdentifier: "filter", for: indexPath) as! ProfileDirectoryFilterView
filterView.updateUI(mastodonController: self.mastodonController)
filterView.onFilterChanged = { [weak self] (scope, order) in
guard let self = self else { return }
self.dataSource.apply(.init())
self.updateProfiles(local: scope == .instance, order: order)
}
return filterView
}
return dataSource
}
private func updateProfiles(local: Bool, order: DirectoryOrder) {
private func updateFilterMenu() {
let scopeMenu = UIMenu(options: .displayInline, children: [
UIAction(title: "Everywhere", subtitle: "Users from the whole network", image: UIImage(systemName: "globe"), state: scope == .everywhere ? .on : .off, handler: { [unowned self] _ in
self.scope = .everywhere
self.updateFilterMenu()
self.updateProfiles()
}),
UIAction(title: mastodonController.accountInfo!.instanceURL.host!, subtitle: "Only users from your instance", image: UIImage(systemName: "house"), state: scope == .instance ? .on : .off, handler: { [unowned self] _ in
self.scope = .instance
self.updateFilterMenu()
self.updateProfiles()
}),
])
let orderMenu = UIMenu(options: .displayInline, children: DirectoryOrder.allCases.map { order in
UIAction(title: order.title, subtitle: order.subtitle, image: nil, state: self.order == order ? .on : .off) { [unowned self] _ in
self.order = order
self.updateFilterMenu()
self.updateProfiles()
}
})
navigationItem.rightBarButtonItem!.menu = UIMenu(children: [
scopeMenu,
orderMenu,
])
}
private func updateProfiles() {
let scope = self.scope
let order = self.order
let local = scope == .everywhere
let request = Client.getFeaturedProfiles(local: local, order: order)
mastodonController.run(request) { (response) in
guard case let .success(accounts, _) = response else {
guard case let .success(accounts, _) = response,
self.scope == scope,
self.order == order else {
return
}
@ -188,3 +204,28 @@ extension ProfileDirectoryViewController: UICollectionViewDragDelegate {
return [UIDragItem(itemProvider: provider)]
}
}
extension ProfileDirectoryViewController {
enum Scope: CaseIterable {
case instance, everywhere
}
}
private extension DirectoryOrder {
var title: String {
switch self {
case .active:
return "Active"
case .new:
return "New"
}
}
var subtitle: String {
switch self {
case .active:
return "Users who have posted lately"
case .new:
return "Recently joined users"
}
}
}

View File

@ -8,19 +8,19 @@
import UIKit
import Pachyderm
import WebURLFoundationExtras
class TrendingHashtagsViewController: EnhancedTableViewController {
class TrendingHashtagsViewController: UIViewController {
weak var mastodonController: MastodonController!
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
super.init(style: .grouped)
dragEnabled = true
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
@ -32,15 +32,24 @@ class TrendingHashtagsViewController: EnhancedTableViewController {
title = NSLocalizedString("Trending Hashtags", comment: "trending hashtags screen title")
tableView.register(UINib(nibName: "TrendingHashtagTableViewCell", bundle: .main), forCellReuseIdentifier: "trendingTagCell")
tableView.rowHeight = 60 // 44 for content + 2 * 8 spacing
view.backgroundColor = .systemGroupedBackground
dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView) { (tableView, indexPath, item) in
let config = UICollectionLayoutListConfiguration(appearance: .grouped)
let layout = UICollectionViewCompositionalLayout.list(using: config)
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.delegate = self
collectionView.dragDelegate = self
view.addSubview(collectionView)
let registration = UICollectionView.CellRegistration<TrendingHashtagCollectionViewCell, Hashtag> { cell, indexPath, hashtag in
cell.updateUI(hashtag: hashtag)
}
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { (collectionView, indexPath, item) in
switch item {
case let .tag(hashtag):
let cell = tableView.dequeueReusableCell(withIdentifier: "trendingTagCell", for: indexPath) as! TrendingHashtagTableViewCell
cell.updateUI(hashtag: hashtag)
return cell
return collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: hashtag)
}
}
}
@ -56,45 +65,9 @@ class TrendingHashtagsViewController: EnhancedTableViewController {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.trendingTags])
snapshot.appendItems(hashtags.map { .tag($0) })
dataSource.apply(snapshot)
await dataSource.apply(snapshot)
}
}
// MARK: - Table View Delegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath),
case let .tag(hashtag) = item else {
return
}
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
}
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let item = dataSource.itemIdentifier(for: indexPath),
case let .tag(hashtag) = item else {
return nil
}
return UIContextMenuConfiguration(identifier: nil) {
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
} actionProvider: { (_) in
UIMenu(children: self.actionsForHashtag(hashtag, sourceView: self.tableView.cellForRow(at: indexPath)))
}
}
override func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let item = dataSource.itemIdentifier(for: indexPath),
case let .tag(hashtag) = item else {
return []
}
let provider = NSItemProvider(object: hashtag.url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) {
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
}
return [UIDragItem(itemProvider: provider)]
}
}
@ -107,6 +80,45 @@ extension TrendingHashtagsViewController {
}
}
extension TrendingHashtagsViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath),
case let .tag(hashtag) = item else {
return
}
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let item = dataSource.itemIdentifier(for: indexPath),
case let .tag(hashtag) = item else {
return nil
}
return UIContextMenuConfiguration(identifier: nil) {
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
} actionProvider: { (_) in
UIMenu(children: self.actionsForHashtag(hashtag, sourceView: self.collectionView.cellForItem(at: indexPath)))
}
}
}
extension TrendingHashtagsViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let item = dataSource.itemIdentifier(for: indexPath),
case let .tag(hashtag) = item,
let url = URL(hashtag.url) else {
return []
}
let provider = NSItemProvider(object: url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) {
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
}
return [UIDragItem(itemProvider: provider)]
}
}
extension TrendingHashtagsViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
}

View File

@ -0,0 +1,141 @@
//
// TrendingLinkCardCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 6/29/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class TrendingLinkCardCollectionViewCell: UICollectionViewCell {
private var card: Card?
private var isGrayscale = false
private var thumbnailRequest: ImageCache.Request?
@IBOutlet weak var thumbnailView: UIImageView!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var providerLabel: UILabel!
@IBOutlet weak var activityLabel: UILabel!
@IBOutlet weak var historyView: TrendHistoryView!
override func awakeFromNib() {
super.awakeFromNib()
layer.shadowOpacity = 0.2
layer.shadowRadius = 8
layer.shadowOffset = .zero
layer.masksToBounds = false
updateLayerColors()
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
}
override func layoutSubviews() {
super.layoutSubviews()
contentView.layer.cornerRadius = 0.05 * bounds.width
thumbnailView.layer.cornerRadius = 0.05 * bounds.width
}
func updateUI(card: Card) {
self.card = card
self.thumbnailView.image = nil
updateGrayscaleableUI(card: card)
updateUIForPreferences()
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
titleLabel.text = title
let provider = card.providerName!.trimmingCharacters(in: .whitespacesAndNewlines)
providerLabel.text = provider
let sorted = card.history!.sorted(by: { $0.day < $1.day })
let lastTwo = sorted[(sorted.count - 2)...]
let accounts = lastTwo.map(\.accounts).reduce(0, +)
let uses = lastTwo.map(\.uses).reduce(0, +)
// U+2009 THIN SPACE
let activityStr = NSMutableAttributedString(string: "\(accounts.formatted())\u{2009}")
activityStr.append(NSAttributedString(attachment: NSTextAttachment(image: UIImage(systemName: "person")!)))
activityStr.append(NSAttributedString(string: ", \(uses.formatted())\u{2009}"))
activityStr.append(NSAttributedString(attachment: NSTextAttachment(image: UIImage(systemName: "square.text.square")!)))
activityLabel.attributedText = activityStr
historyView.setHistory(card.history)
historyView.isHidden = card.history == nil || card.history!.count < 2
}
@objc private func updateUIForPreferences() {
if isGrayscale != Preferences.shared.grayscaleImages,
let card {
updateGrayscaleableUI(card: card)
}
}
private func updateGrayscaleableUI(card: Card) {
isGrayscale = Preferences.shared.grayscaleImages
if let imageURL = card.image,
let url = URL(imageURL) {
thumbnailRequest = ImageCache.attachments.get(url, completion: { _, image in
guard let image,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) else {
return
}
DispatchQueue.main.async {
self.thumbnailView.image = transformedImage
}
})
if thumbnailRequest != nil {
loadBlurHash(card: card)
}
}
}
private func loadBlurHash(card: Card) {
guard let hash = card.blurhash else {
return
}
let imageViewSize = self.thumbnailView.bounds.size
AttachmentView.queue.async { [weak self] in
let size: CGSize
if let width = card.width, let height = card.height {
size = CGSize(width: width, height: height)
} else {
size = imageViewSize
}
guard let preview = UIImage(blurHash: hash, size: size) else {
return
}
DispatchQueue.main.async { [weak self] in
guard let self,
self.card?.url == card.url,
self.thumbnailView.image == nil else {
return
}
self.thumbnailView.image = preview
}
}
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
updateLayerColors()
}
private func updateLayerColors() {
if traitCollection.userInterfaceStyle == .dark {
// clippingView.layer.borderColor = UIColor.darkGray.withAlphaComponent(0.5).cgColor
layer.shadowColor = UIColor.darkGray.cgColor
} else {
// clippingView.layer.borderColor = UIColor.lightGray.withAlphaComponent(0.5).cgColor
layer.shadowColor = UIColor.black.cgColor
}
}
}

View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21179.7" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_0" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21169.4"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="collection view cell content view" 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"/>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" id="izA-ZZ-g7F" customClass="TrendingLinkCardCollectionViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="300" height="400"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<collectionViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="Zb0-aW-Sen">
<rect key="frame" x="0.0" y="0.0" width="300" height="400"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="h3b-Mf-lD6">
<rect key="frame" x="0.0" y="0.0" width="300" height="225"/>
<constraints>
<constraint firstAttribute="width" secondItem="h3b-Mf-lD6" secondAttribute="height" multiplier="4:3" id="QDY-8a-LYC"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Ho3-cU-IGi">
<rect key="frame" x="16" y="330.66666666666674" width="268" height="20.333333333333314"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Provider" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="O9r-10-LDD">
<rect key="frame" x="16.000000000000004" y="355" width="57.333333333333343" height="18"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Activity" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ULe-Gd-t1S">
<rect key="frame" x="16" y="377" width="43" height="15"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="LZj-Ii-63i" customClass="TrendHistoryView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="200" y="355" width="100" height="44"/>
<constraints>
<constraint firstAttribute="width" constant="100" id="cUc-p7-aLH"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="bottomMargin" secondItem="ULe-Gd-t1S" secondAttribute="bottom" id="6UL-8b-Aia"/>
<constraint firstItem="h3b-Mf-lD6" firstAttribute="top" secondItem="Zb0-aW-Sen" secondAttribute="top" id="EFg-Yr-vdt"/>
<constraint firstItem="Ho3-cU-IGi" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leadingMargin" id="Ga8-LQ-f4N"/>
<constraint firstItem="ULe-Gd-t1S" firstAttribute="top" secondItem="O9r-10-LDD" secondAttribute="bottom" constant="4" id="HPD-qN-k3z"/>
<constraint firstAttribute="bottom" secondItem="LZj-Ii-63i" secondAttribute="bottom" constant="1" id="HWu-In-Uem"/>
<constraint firstItem="O9r-10-LDD" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leadingMargin" id="Hz8-Bw-jpl"/>
<constraint firstAttribute="trailing" secondItem="LZj-Ii-63i" secondAttribute="trailing" id="J9c-CF-3EF"/>
<constraint firstItem="ULe-Gd-t1S" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leadingMargin" id="KEj-En-StX"/>
<constraint firstItem="Ho3-cU-IGi" firstAttribute="top" secondItem="h3b-Mf-lD6" secondAttribute="bottom" constant="4" id="PjW-V1-oDs"/>
<constraint firstItem="LZj-Ii-63i" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="O9r-10-LDD" secondAttribute="trailing" id="WNr-ZP-o9a"/>
<constraint firstItem="LZj-Ii-63i" firstAttribute="top" secondItem="Ho3-cU-IGi" secondAttribute="bottom" constant="4" id="fpM-Hp-Oyf"/>
<constraint firstAttribute="trailing" secondItem="h3b-Mf-lD6" secondAttribute="trailing" id="kBD-1R-bh7"/>
<constraint firstItem="LZj-Ii-63i" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="ULe-Gd-t1S" secondAttribute="trailing" id="ruZ-p8-n0x"/>
<constraint firstAttribute="trailingMargin" secondItem="Ho3-cU-IGi" secondAttribute="trailing" id="ubj-f6-bXE"/>
<constraint firstItem="h3b-Mf-lD6" firstAttribute="leading" secondItem="Zb0-aW-Sen" secondAttribute="leading" id="wF1-Gm-nVQ"/>
<constraint firstItem="O9r-10-LDD" firstAttribute="top" secondItem="Ho3-cU-IGi" secondAttribute="bottom" constant="4" id="yPq-dT-uib"/>
</constraints>
</collectionViewCellContentView>
<connections>
<outlet property="activityLabel" destination="ULe-Gd-t1S" id="wqe-G6-IB3"/>
<outlet property="historyView" destination="LZj-Ii-63i" id="MVF-az-uyA"/>
<outlet property="providerLabel" destination="O9r-10-LDD" id="xAF-NW-ymm"/>
<outlet property="thumbnailView" destination="h3b-Mf-lD6" id="4mF-bJ-ALY"/>
<outlet property="titleLabel" destination="Ho3-cU-IGi" id="ltu-ey-chT"/>
</connections>
<point key="canvasLocation" x="0.0" y="-13.507109004739336"/>
</collectionViewCell>
</objects>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View File

@ -113,6 +113,10 @@ class TrendingLinkTableViewCell: UITableViewCell {
}
@objc private func updateUIForPreferences() {
if isGrayscale != Preferences.shared.grayscaleImages,
let card {
updateGrayscaleableUI(card: card)
}
}
private func updateGrayscaleableUI(card: Card) {

View File

@ -55,7 +55,7 @@ class TrendingLinksViewController: EnhancedTableViewController {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.links])
snapshot.appendItems(links.map(Item.init))
dataSource.apply(snapshot)
await dataSource.apply(snapshot)
}
}

View File

@ -9,6 +9,7 @@
import UIKit
import Pachyderm
import AVFoundation
import VisionKit
protocol LargeImageContentView: UIView {
var animationImage: UIImage? { get }
@ -16,7 +17,12 @@ protocol LargeImageContentView: UIView {
func grayscaleStateChanged()
}
class LargeImageImageContentView: GIFImageView, LargeImageContentView {
class LargeImageImageContentView: UIImageView, LargeImageContentView {
#if !targetEnvironment(macCatalyst)
@available(iOS 16.0, *)
private static let analyzer = ImageAnalyzer()
#endif
var animationImage: UIImage? { image! }
@ -25,13 +31,33 @@ class LargeImageImageContentView: GIFImageView, LargeImageContentView {
}
private var sourceData: Data?
private weak var owner: UIViewController?
init(image: UIImage) {
init(image: UIImage, owner: UIViewController?) {
self.owner = owner
super.init(image: image)
contentMode = .scaleAspectFit
isUserInteractionEnabled = true
#if !targetEnvironment(macCatalyst)
if #available(iOS 16.0, *),
ImageAnalyzer.isSupported {
let interaction = ImageAnalysisInteraction()
interaction.delegate = self
interaction.preferredInteractionTypes = .automatic
addInteraction(interaction)
Task {
do {
let result = try await LargeImageImageContentView.analyzer.analyze(image, configuration: ImageAnalyzer.Configuration([.text, .machineReadableCode]))
interaction.analysis = result
} catch {
// if analysis fails, we just don't show anything
}
}
}
#endif
}
required init?(coder: NSCoder) {
@ -56,6 +82,15 @@ class LargeImageImageContentView: GIFImageView, LargeImageContentView {
}
}
#if !targetEnvironment(macCatalyst)
@available(iOS 16.0, *)
extension LargeImageImageContentView: ImageAnalysisInteractionDelegate {
func presentingViewController(for interaction: ImageAnalysisInteraction) -> UIViewController? {
return owner
}
}
#endif
class LargeImageGifContentView: GIFImageView, LargeImageContentView {
var animationImage: UIImage? { image }

View File

@ -138,9 +138,9 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
content = LargeImageGifContentView(gifController: gifController)
} else {
if let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) {
content = LargeImageImageContentView(image: transformedImage)
content = LargeImageImageContentView(image: transformedImage, owner: self)
} else {
content = LargeImageImageContentView(image: image)
content = LargeImageImageContentView(image: image, owner: self)
}
}
@ -167,7 +167,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: source) {
image = grayscale
}
setContent(LargeImageImageContentView(image: image))
setContent(LargeImageImageContentView(image: image, owner: self))
}
}

View File

@ -37,7 +37,7 @@ class MainSidebarViewController: UIViewController {
}
var exploreTabItems: [Item] {
var items: [Item] = [.search, .bookmarks, .trendingStatuses, .trendingTags, .trendingLinks, .profileDirectory]
var items: [Item] = [.explore, .bookmarks, .trendingStatuses, .profileDirectory]
let snapshot = dataSource.snapshot()
for case let .list(list) in snapshot.itemIdentifiers(inSection: .lists) {
items.append(.list(list))
@ -154,7 +154,7 @@ class MainSidebarViewController: UIViewController {
snapshot.appendItems([
.tab(.timelines),
.tab(.notifications),
.search,
.explore,
.bookmarks,
.tab(.myProfile)
], toSection: .tabs)
@ -177,12 +177,10 @@ class MainSidebarViewController: UIViewController {
var discoverSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
discoverSnapshot.append([.discoverHeader])
discoverSnapshot.append([
.trendingTags,
.profileDirectory,
], to: .discoverHeader)
if mastodonController.instanceFeatures.trendingStatusesAndLinks {
discoverSnapshot.insert([.trendingStatuses], before: .trendingTags)
discoverSnapshot.insert([.trendingLinks], after: .trendingTags)
discoverSnapshot.insert([.trendingStatuses], before: .profileDirectory)
}
dataSource.apply(discoverSnapshot, to: .discover)
}
@ -345,7 +343,7 @@ class MainSidebarViewController: UIViewController {
return UserActivityManager.checkNotificationsActivity(mode: Preferences.shared.defaultNotificationsMode)
case .tab(.compose):
return UserActivityManager.newPostActivity(accountID: id)
case .search:
case .explore:
return UserActivityManager.searchActivity()
case .bookmarks:
return UserActivityManager.bookmarksActivity()
@ -384,8 +382,8 @@ extension MainSidebarViewController {
}
enum Item: Hashable {
case tab(MainTabBarViewController.Tab)
case search, bookmarks
case discoverHeader, trendingStatuses, trendingTags, trendingLinks, profileDirectory
case explore, bookmarks
case discoverHeader, trendingStatuses, profileDirectory
case listsHeader, list(List), addList
case savedHashtagsHeader, savedHashtag(Hashtag), addSavedHashtag
case savedInstancesHeader, savedInstance(URL), addSavedInstance
@ -394,18 +392,14 @@ extension MainSidebarViewController {
switch self {
case let .tab(tab):
return tab.title
case .search:
return "Search"
case .explore:
return "Explore"
case .bookmarks:
return "Bookmarks"
case .discoverHeader:
return "Discover"
case .trendingStatuses:
return "Trending Posts"
case .trendingTags:
return "Trending Hashtags"
case .trendingLinks:
return "Trending Links"
case .profileDirectory:
return "Profile Directory"
case .listsHeader:
@ -433,16 +427,12 @@ extension MainSidebarViewController {
switch self {
case let .tab(tab):
return tab.imageName
case .search:
case .explore:
return "magnifyingglass"
case .bookmarks:
return "bookmark"
case .trendingStatuses:
return "doc.text.image"
case .trendingTags:
return "number"
case .trendingLinks:
return "link"
return "square.text.square"
case .profileDirectory:
return "person.2.fill"
case .list(_):
@ -550,8 +540,7 @@ extension MainSidebarViewController: UICollectionViewDelegate {
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard #available(iOS 15.0, *),
let item = dataSource.itemIdentifier(for: indexPath),
guard let item = dataSource.itemIdentifier(for: indexPath),
let activity = userActivityForItem(item) else {
return nil
}

View File

@ -20,8 +20,11 @@ class MainSplitViewController: UISplitViewController {
private var tabBarViewController: MainTabBarViewController!
private var secondaryNavController: UINavigationController! {
viewController(for: .secondary) as? UINavigationController
// private var secondaryNavController: UINavigationController! {
// viewController(for: .secondary) as? UINavigationController
// }
private var secondaryNavController: SplitNavigationController! {
viewController(for: .secondary) as? SplitNavigationController
}
init(mastodonController: MastodonController) {
@ -46,7 +49,10 @@ class MainSplitViewController: UISplitViewController {
setViewController(sidebar, for: .primary)
primaryBackgroundStyle = .sidebar
setViewController(EnhancedNavigationViewController(), for: .secondary)
// let secondaryNav = EnhancedNavigationViewController()
// secondaryNav.useBrowserStyleNavigation = true
let splitNav = SplitNavigationController()
setViewController(splitNav, for: .secondary)
// don't unnecesarily construct a content VC unless the we're in actually split mode
// when we change from compact -> split for the first time, the VC will be transferred anyways
if traitCollection.horizontalSizeClass != .compact {
@ -98,7 +104,7 @@ class MainSplitViewController: UISplitViewController {
item = .tab(MainTabBarViewController.Tab(rawValue: index)!)
} else if let str = command.propertyList as? String {
if str == "search" {
item = .search
item = .explore
} else if str == "bookmarks" {
item = .bookmarks
} else {
@ -169,7 +175,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
$0.1 > $1.1
}
if let mostRecentExploreItem = mostRecentExploreItem?.0,
mostRecentExploreItem != .search {
mostRecentExploreItem != .explore {
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController
// Pop back to root, so we're appending to the Explore VC instead of some other VC
exploreNav.popToRootViewController(animated: false)
@ -186,7 +192,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
// sidebar items that map 1 <-> 1 can be transferred directly
tabBarViewController.select(tab: tab)
case .search:
case .explore:
// Search sidebar item maps to the Explore tab with the search controller/results visible
// The nav stack can't be copied directly, since the split VC uses a different SearchViewController
// so that explore items aren't shown multiple times.
@ -215,11 +221,11 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
explore.resultsController.loadResults(from: search.resultsController)
// Transfer the navigation stack, dropping the search VC, to keep anything the user has opened
transferNavigationStack(from: .search, to: exploreNav, dropFirst: true, append: true)
transferNavigationStack(from: .explore, to: exploreNav, dropFirst: true, append: true)
tabBarViewController.select(tab: .explore)
case .bookmarks, .trendingStatuses, .trendingTags, .trendingLinks, .profileDirectory, .list(_), .savedHashtag(_), .savedInstance(_):
case .bookmarks, .trendingStatuses, .profileDirectory, .list(_), .savedHashtag(_), .savedInstance(_):
tabBarViewController.select(tab: .explore)
// Make sure the Explore VC doesn't show it's search bar when it appears, in case the user was previously
// in compact mode and performing a search.
@ -270,7 +276,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
// For other items, the 2nd VC in the nav stack determines which sidebar item they map to.
// Search screen has special considerations, all others can be transferred directly.
if tabNavigationStack.count == 1 || ((tabNavigationStack.first as? ExploreViewController)?.searchController?.isActive ?? false) {
exploreItem = .search
exploreItem = .explore
let searchVC = SearchViewController(mastodonController: mastodonController)
searchVC.loadViewIfNeeded()
let explore = tabNavigationStack.first as! ExploreViewController
@ -298,9 +304,9 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
case is TrendingStatusesViewController:
exploreItem = .trendingStatuses
case is TrendingHashtagsViewController:
exploreItem = .trendingTags
exploreItem = .explore
case is TrendingLinksViewController:
exploreItem = .trendingLinks
exploreItem = .explore
case is ProfileDirectoryViewController:
exploreItem = .profileDirectory
default:
@ -352,16 +358,12 @@ fileprivate extension MainSidebarViewController.Item {
switch self {
case let .tab(tab):
return tab.createViewController(mastodonController)
case .search:
case .explore:
return SearchViewController(mastodonController: mastodonController)
case .bookmarks:
return BookmarksTableViewController(mastodonController: mastodonController)
case .trendingStatuses:
return TrendingStatusesViewController(mastodonController: mastodonController)
case .trendingTags:
return TrendingHashtagsViewController(mastodonController: mastodonController)
case .trendingLinks:
return TrendingLinksViewController(mastodonController: mastodonController)
case .profileDirectory:
return ProfileDirectoryViewController(mastodonController: mastodonController)
case let .list(list):
@ -378,7 +380,7 @@ fileprivate extension MainSidebarViewController.Item {
extension MainSplitViewController: TuskerRootViewController {
@objc func presentCompose() {
if #available(iOS 15.0, *), UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
let compose = UserActivityManager.newPostActivity(mentioning: nil, accountID: mastodonController.accountInfo!.id)
let options = UIWindowScene.ActivationRequestOptions()
options.preferredPresentationStyle = .prominent
@ -433,8 +435,8 @@ extension MainSplitViewController: TuskerRootViewController {
return
}
if sidebar.selectedItem != .search {
select(item: .search)
if sidebar.selectedItem != .explore {
select(item: .explore)
}
guard let searchViewController = secondaryNavController.viewControllers.first as? SearchViewController else {

View File

@ -141,7 +141,9 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
if let vc = vc as? UINavigationController {
return vc
} else {
return EnhancedNavigationViewController(rootViewController: vc)
let nav = EnhancedNavigationViewController(rootViewController: vc)
// nav.useBrowserStyleNavigation = true
return nav
}
}
@ -228,7 +230,7 @@ extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate {
extension MainTabBarViewController: TuskerRootViewController {
@objc func presentCompose() {
if #available(iOS 15.0, *), UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
let compose = UserActivityManager.newPostActivity(mentioning: nil, accountID: mastodonController.accountInfo!.id)
let options = UIWindowScene.ActivationRequestOptions()
options.preferredPresentationStyle = .prominent

View File

@ -107,11 +107,13 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
case let .failure(error):
completion(.failure(.client(error)))
case let .success(notifications, pagination):
case let .success(notifications, _):
let groups = NotificationGroup.createGroups(notifications: notifications, only: self.groupTypes)
self.newer = pagination?.newer
self.older = pagination?.older
if !notifications.isEmpty {
self.newer = .after(id: notifications.first!.id, count: nil)
self.older = .before(id: notifications.last!.id, count: nil)
}
self.mastodonController.persistentContainer.addAll(notifications: notifications) {
var snapshot = Snapshot()
@ -135,9 +137,9 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
case let .failure(error):
completion(.failure(.client(error)))
case let .success(newNotifications, pagination):
if let older = pagination?.older {
self.older = older
case let .success(newNotifications, _):
if !newNotifications.isEmpty {
self.older = .before(id: newNotifications.last!.id, count: nil)
}
let olderGroups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
@ -166,15 +168,13 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
case let .failure(error):
completion(.failure(.client(error)))
case let .success(newNotifications, pagination):
case let .success(newNotifications, _):
guard !newNotifications.isEmpty else {
completion(.failure(.allCaughtUp))
return
}
if let newer = pagination?.newer {
self.newer = newer
}
self.newer = .after(id: newNotifications.first!.id, count: nil)
let newerGroups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
@ -255,7 +255,10 @@ extension NotificationsTableViewController: MenuActionProvider {
extension NotificationsTableViewController: StatusTableViewCellDelegate {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
cellHeightChanged()
if #available(iOS 16.0, *) {
} else {
cellHeightChanged()
}
}
}

View File

@ -81,6 +81,9 @@ class InstanceSelectorTableViewController: UITableViewController {
searchController.searchBar.searchTextField.autocapitalizationType = .none
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
if #available(iOS 16.0, *) {
navigationItem.preferredSearchBarPlacement = .stacked
}
definesPresentationContext = true
urlHandler = urlCheckerSubject

View File

@ -38,7 +38,7 @@ struct OppositeCollapseKeywordsView: View {
FocusableTextField(placeholder: "Add Keyword", text: $valueToAdd, becomeFirstResponder: $makeAddFieldFirstResponder, onCommit: self.addKeyword)
}
}
.animation(.default)
.animation(.default, value: keywords.map(\.id))
.listStyle(GroupedListStyle())
}
.onAppear(perform: updateAppearance)
@ -46,7 +46,11 @@ struct OppositeCollapseKeywordsView: View {
}
private func updateAppearance() {
UIScrollView.appearance(whenContainedInInstancesOf: [PreferencesNavigationController.self]).keyboardDismissMode = .interactive
if #available(iOS 16.0, *) {
// no longer necessary
} else {
UIScrollView.appearance(whenContainedInInstancesOf: [PreferencesNavigationController.self]).keyboardDismissMode = .interactive
}
}
private func commitExisting(at index: Int) -> () -> Void {

View File

@ -16,9 +16,7 @@ struct WellnessPrefsView: View {
showFavAndReblogCount
notificationsMode
grayscaleImages
if #available(iOS 15.0, *) {
disableInfiniteScrolling
}
disableInfiniteScrolling
hideDiscover
}
.listStyle(InsetGroupedListStyle())

View File

@ -265,7 +265,10 @@ extension ProfileStatusesViewController: TuskerNavigationDelegate {
extension ProfileStatusesViewController: StatusTableViewCellDelegate {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
cellHeightChanged()
if #available(iOS 16.0, *) {
} else {
cellHeightChanged()
}
}
}

View File

@ -7,11 +7,17 @@
//
import UIKit
import Pachyderm
import SafariServices
import WebURLFoundationExtras
class SearchViewController: UIViewController {
weak var mastodonController: MastodonController!
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var resultsController: SearchResultsViewController!
var searchController: UISearchController!
@ -22,7 +28,7 @@ class SearchViewController: UIViewController {
super.init(nibName: nil, bundle: nil)
title = NSLocalizedString("Search", comment: "search tab title")
title = NSLocalizedString("Explore", comment: "explore tab title")
}
required init?(coder: NSCoder) {
@ -32,12 +38,46 @@ class SearchViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
let sectionIdentifier = self.dataSource.snapshot().sectionIdentifiers[sectionIndex]
switch sectionIdentifier {
case .trendingHashtags:
var listConfig = UICollectionLayoutListConfiguration(appearance: .grouped)
listConfig.headerMode = .supplementary
return .list(using: listConfig, layoutEnvironment: environment)
case .trendingLinks:
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
// todo: i really wish i could just say the height is automatic and let autolayout figure out what it needs to be
// using .estimated(whatever) constrains the height to exactly whatever
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(250), heightDimension: .estimated(280))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
group.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .fixed(8), top: nil, trailing: .fixed(8), bottom: nil)
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
section.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(12)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading)
]
return section
default:
fatalError("unimplemented")
}
}
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.backgroundColor = .secondarySystemBackground
view.addSubview(collectionView)
dataSource = createDataSource()
resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController
searchController = UISearchController(searchResultsController: resultsController)
searchController.obscuresBackgroundDuringPresentation = false
searchController.obscuresBackgroundDuringPresentation = true
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.delegate = resultsController
searchController.hidesNavigationBarDuringPresentation = false
@ -45,6 +85,21 @@ class SearchViewController: UIViewController {
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
if #available(iOS 16.0, *) {
navigationItem.preferredSearchBarPlacement = .stacked
}
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task(priority: .userInitiated) {
if (try? await mastodonController.getOwnInstance()) != nil {
await applySnapshot()
}
}
}
override func viewDidAppear(_ animated: Bool) {
@ -58,5 +113,217 @@ class SearchViewController: UIViewController {
searchControllerStatusOnAppearance = nil
}
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { [unowned self] (headerView, collectionView, indexPath) in
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
var config = UIListContentConfiguration.groupedHeader()
config.text = section.title
headerView.contentConfiguration = config
}
let trendingHashtagCell = UICollectionView.CellRegistration<TrendingHashtagCollectionViewCell, Hashtag> { (cell, indexPath, hashtag) in
cell.updateUI(hashtag: hashtag)
}
let trendingLinkCell = UICollectionView.CellRegistration<TrendingLinkCardCollectionViewCell, Card>(cellNib: UINib(nibName: "TrendingLinkCardCollectionViewCell", bundle: .main)) { (cell, indexPath, card) in
cell.updateUI(card: card)
}
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item in
switch item {
case let .tag(hashtag):
return collectionView.dequeueConfiguredReusableCell(using: trendingHashtagCell, for: indexPath, item: hashtag)
case let .link(card):
return collectionView.dequeueConfiguredReusableCell(using: trendingLinkCell, for: indexPath, item: card)
default:
fatalError("todo")
}
}
dataSource.supplementaryViewProvider = { (collectionView, elementKind, indexPath) in
if elementKind == UICollectionView.elementKindSectionHeader {
return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderCell, for: indexPath)
} else {
return nil
}
}
return dataSource
}
@MainActor
private func applySnapshot() async {
guard mastodonController.instanceFeatures.trends,
!Preferences.shared.hideDiscover else {
await dataSource.apply(NSDiffableDataSourceSnapshot())
return
}
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
let hashtagsReq = Client.getTrendingHashtags(limit: 5)
async let hashtags = try? mastodonController.run(hashtagsReq).0
let linksReq = Client.getTrendingLinks(limit: 10)
async let links = try? mastodonController.run(linksReq).0
if let hashtags = await hashtags {
snapshot.appendSections([.trendingHashtags])
snapshot.appendItems(hashtags.map { .tag($0) }, toSection: .trendingHashtags)
}
if let links = await links {
snapshot.appendSections([.trendingLinks])
snapshot.appendItems(links.map { .link($0) }, toSection: .trendingLinks)
}
await dataSource.apply(snapshot)
}
@objc private func preferencesChanged() {
Task {
await applySnapshot()
}
}
}
extension SearchViewController {
enum Section {
case trendingHashtags
case trendingLinks
case trendingStatuses
case profileSuggestions
var title: String {
switch self {
case .trendingHashtags:
return "Trending Hashtags"
case .trendingLinks:
return "Trending Links"
case .trendingStatuses:
return "Trending Statuses"
case .profileSuggestions:
return "Suggested Accounts"
}
}
}
enum Item: Equatable, Hashable {
case status(String)
case tag(Hashtag)
case link(Card)
static func == (lhs: SearchViewController.Item, rhs: SearchViewController.Item) -> Bool {
switch (lhs, rhs) {
case let (.status(a), .status(b)):
return a == b
case let (.tag(a), .tag(b)):
return a == b
case let (.link(a), .link(b)):
return a.url == b.url
default:
return false
}
}
func hash(into hasher: inout Hasher) {
switch self {
case let .status(id):
hasher.combine("status")
hasher.combine(id)
case let .tag(tag):
hasher.combine("tag")
hasher.combine(tag.name)
case let .link(card):
hasher.combine("link")
hasher.combine(card.url)
}
}
}
}
extension SearchViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else {
return
}
switch item {
case let .tag(hashtag):
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
case let .link(card):
if let url = URL(card.url) {
selected(url: url)
}
default:
fatalError("todo")
}
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let item = dataSource.itemIdentifier(for: indexPath) else {
return nil
}
switch item {
case let .tag(hashtag):
return UIContextMenuConfiguration(identifier: nil) {
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
} actionProvider: { (_) in
UIMenu(children: self.actionsForHashtag(hashtag, sourceView: self.collectionView.cellForItem(at: indexPath)))
}
case let .link(card):
guard let url = URL(card.url) else {
return nil
}
return UIContextMenuConfiguration {
SFSafariViewController(url: url)
} actionProvider: { _ in
UIMenu(children: self.actionsForTrendingLink(card: card))
}
default:
fatalError("todo")
}
}
}
extension SearchViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let item = dataSource.itemIdentifier(for: indexPath) else {
return []
}
switch item {
case let .tag(hashtag):
guard let url = URL(hashtag.url) else {
return []
}
let provider = NSItemProvider(object: url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) {
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
}
return [UIDragItem(itemProvider: provider)]
case let .link(card):
guard let url = URL(card.url) else {
return []
}
return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))]
default:
fatalError("todo")
}
}
}
extension SearchViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
}
extension SearchViewController: ToastableViewController {
}
extension SearchViewController: MenuActionProvider {
}

View File

@ -164,8 +164,7 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
return
}
if #available(iOS 15.0, *),
Preferences.shared.disableInfiniteScrolling && !didConfirmLoadMore {
if Preferences.shared.disableInfiniteScrolling && !didConfirmLoadMore {
var snapshot = currentSnapshot()
guard !snapshot.itemIdentifiers(inSection: .footer).contains(.confirmLoadMore) else {
// todo: need something more accurate than "success"/"failure"
@ -290,7 +289,10 @@ extension TimelineTableViewController: TuskerNavigationDelegate {
extension TimelineTableViewController: StatusTableViewCellDelegate {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
cellHeightChanged()
if #available(iOS 16.0, *) {
} else {
cellHeightChanged()
}
}
}

View File

@ -34,7 +34,9 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
override func viewDidLoad() {
super.viewDidLoad()
dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: self.cellProvider)
dataSource = UITableViewDiffableDataSource(tableView: tableView) { [unowned self] (tableView, indexPath, item) in
self.cellProvider(tableView, indexPath, item)
}
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 140
@ -161,6 +163,7 @@ class DiffableTimelineLikeTableViewController<Section: Hashable & CaseIterable,
}
}
@available(iOS, deprecated: 16.0)
func cellHeightChanged() {
// causes the table view to recalculate the cell heights
tableView.beginUpdates()

View File

@ -9,43 +9,79 @@
import UIKit
class EnhancedNavigationViewController: UINavigationController {
var useBrowserStyleNavigation = false
var poppedViewControllers = [UIViewController]()
var skipResetPoppedOnNextPush = false
private var interactivePushTransition: InteractivePushTransition!
override var viewControllers: [UIViewController] {
didSet {
poppedViewControllers = []
if #available(iOS 16.0, *) {
// TODO: this for loop might not be necessary
for vc in viewControllers {
configureNavItem(vc.navigationItem)
}
updateTopNavItemState()
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
self.interactivePushTransition = InteractivePushTransition(navigationController: self)
if #available(iOS 16.0, *),
let topViewController {
configureNavItem(topViewController.navigationItem)
updateTopNavItemState()
}
}
override func popViewController(animated: Bool) -> UIViewController? {
if let popped = super.popViewController(animated: animated) {
let popped = performAfterAnimating(block: {
super.popViewController(animated: animated)
}, after: {
if #available(iOS 16.0, *) {
self.updateTopNavItemState()
}
}, animated: animated)
if let popped {
poppedViewControllers.insert(popped, at: 0)
return popped
} else {
return nil
}
return popped
}
override func popToRootViewController(animated: Bool) -> [UIViewController]? {
if let popped = super.popToRootViewController(animated: animated) {
let popped = performAfterAnimating(block: {
super.popToRootViewController(animated: animated)
}, after: {
if #available(iOS 16.0, *) {
self.updateTopNavItemState()
}
}, animated: animated)
if let popped {
poppedViewControllers = popped
return popped
} else {
return nil
}
return popped
}
override func popToViewController(_ viewController: UIViewController, animated: Bool) -> [UIViewController]? {
if let popped = super.popToViewController(viewController, animated: animated) {
let popped = performAfterAnimating(block: {
super.popToViewController(viewController, animated: animated)
}, after: {
if #available(iOS 16.0, *) {
self.updateTopNavItemState()
}
}, animated: animated)
if let popped {
poppedViewControllers.insert(contentsOf: popped, at: 0)
return popped
} else {
return nil
}
return popped
}
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
@ -54,7 +90,49 @@ class EnhancedNavigationViewController: UINavigationController {
} else {
self.poppedViewControllers = []
}
if #available(iOS 16.0, *) {
configureNavItem(viewController.navigationItem)
}
super.pushViewController(viewController, animated: animated)
if #available(iOS 16.0, *) {
updateTopNavItemState()
}
}
func pushPoppedViewController() {
guard !poppedViewControllers.isEmpty else {
return
}
skipResetPoppedOnNextPush = true
pushViewController(poppedViewControllers.removeFirst(), animated: true)
}
func pushToPoppedViewController(_ target: UIViewController) {
guard poppedViewControllers.contains(target) else {
return
}
var toInsert: [UIViewController] = []
while true {
let vc = poppedViewControllers.removeFirst()
if vc == target {
break
} else {
toInsert.append(vc)
}
}
// match the system behavior when popping multiple by animated-ly pushing the final destination one,
// and then intersiting the intermediary ones before it, as if they'd all been pushed together
performAfterAnimating(block: {
pushViewController(target, animated: true)
}, after: {
self.viewControllers.insert(contentsOf: toInsert, at: self.viewControllers.count - 1)
if #available(iOS 16.0, *) {
self.updateTopNavItemState()
}
}, animated: true)
}
func onWillShow() {
@ -72,6 +150,94 @@ class EnhancedNavigationViewController: UINavigationController {
}
})
}
@available(iOS 16.0, *)
private func configureNavItem(_ navItem: UINavigationItem) {
guard useBrowserStyleNavigation,
UIDevice.current.userInterfaceIdiom != .phone else {
return
}
navItem.style = .browser
navItem.hidesBackButton = true
if let titleView = navItem.titleView,
titleView.tag != ViewTags.navEmptyTitleView {
// blergh, i don't like changing this out from under some other view controller
// we use an empty view because otherwise the title label displays in addition to the new title view bar button item
navItem.titleView = UIView()
navItem.titleView?.tag = ViewTags.navEmptyTitleView
// TODO: centerItemGroups don't animate out during nav transitions, the just (dis)appear abruptly
navItem.centerItemGroups = [
.fixedGroup(items: [UIBarButtonItem(customView: titleView)])
]
}
let back = UIBarButtonItem(image: UIImage(systemName: "chevron.backward"), style: .plain, target: self, action: #selector(goBack))
back.tag = ViewTags.navBackBarButton
back.menu = UIMenu(children: [
UIDeferredMenuElement({ [unowned self] completion in
completion(self.viewControllers.dropLast(1).reversed().map { vc in
UIAction(title: vc.navigationItem.title ?? "Back") { [weak self] _ in
_ = self?.popToViewController(vc, animated: true)
}
})
})
])
let forward = UIBarButtonItem(image: UIImage(systemName: "chevron.forward"), style: .plain, target: self, action: #selector(goForward))
forward.tag = ViewTags.navForwardBarButton
forward.menu = UIMenu(children: [
UIDeferredMenuElement.uncached({ [unowned self] completion in
completion(poppedViewControllers.map { vc in
UIAction(title: vc.navigationItem.title ?? "Forward") { [weak self] _ in
self?.pushToPoppedViewController(vc)
}
})
})
])
navItem.leadingItemGroups = [
.fixedGroup(items: [
back,
forward,
])
]
}
@available(iOS 16.0, *)
private func updateTopNavItemState() {
guard useBrowserStyleNavigation,
UIDevice.current.userInterfaceIdiom != .phone,
let vc = topViewController,
let group = vc.navigationItem.leadingItemGroups.first,
group.barButtonItems.count == 2,
group.barButtonItems[0].tag == ViewTags.navBackBarButton,
group.barButtonItems[1].tag == ViewTags.navForwardBarButton else {
return
}
group.barButtonItems[0].isEnabled = viewControllers.count > 1
group.barButtonItems[1].isEnabled = !poppedViewControllers.isEmpty
}
private func performAfterAnimating<R>(block: () -> R, after: @escaping () -> Void, animated: Bool) -> R {
if animated {
CATransaction.begin()
let result = block()
CATransaction.setCompletionBlock {
after()
}
CATransaction.commit()
return result
} else {
let result = block()
after()
return result
}
}
@objc private func goBack() {
_ = popViewController(animated: true)
}
@objc private func goForward() {
pushPoppedViewController()
}
}

View File

@ -9,6 +9,7 @@
import UIKit
import SafariServices
import Pachyderm
import WebURLFoundationExtras
protocol MenuActionProvider: AnyObject {
var navigationDelegate: TuskerNavigationDelegate? { get }
@ -59,7 +60,7 @@ extension MenuActionProvider {
draft.visibility = .direct
self.navigationDelegate?.compose(editing: draft)
}),
UIDeferredMenuElement.uncachedIfPossible({ (elementHandler) in
UIDeferredMenuElement.uncached({ (elementHandler) in
Task { @MainActor in
if let action = await self.followAction(for: accountID, mastodonController: mastodonController) {
elementHandler([action])
@ -116,7 +117,12 @@ extension MenuActionProvider {
actionsSection = []
}
let shareSection = actionsForURL(hashtag.url, sourceView: sourceView)
let shareSection: [UIMenuElement]
if let url = URL(hashtag.url) {
shareSection = actionsForURL(url, sourceView: sourceView)
} else {
shareSection = []
}
return [
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
@ -137,10 +143,9 @@ extension MenuActionProvider {
})
]
}
let bookmarked = status.bookmarked ?? false
var actionsSection = [
var toggleableSection = [
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)
@ -161,6 +166,57 @@ extension MenuActionProvider {
}),
]
if #available(iOS 16.0, *) {
let favorited = status.favourited
// TODO: move this color into an asset catalog or something
var favImage = UIImage(systemName: favorited ? "star.fill" : "star")!
if favorited {
favImage = favImage.withTintColor(UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1), renderingMode: .alwaysOriginal)
}
toggleableSection.insert(createAction(identifier: "favorite", title: favorited ? "Unfavorite" : "Favorite", image: favImage, handler: { [weak self] _ in
guard let self = self else { return }
let request = (favorited ? Status.favourite : Status.unfavourite)(status.id)
self.mastodonController?.run(request, completion: { response in
switch response {
case .success(let status, _):
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
case .failure(let error):
if let toastable = self.toastableViewController {
let config = ToastConfiguration(from: error, with: "Error \(favorited ? "Unf" : "F")avoriting", in: toastable, retryAction: nil)
DispatchQueue.main.async {
toastable.showToast(configuration: config, animated: true)
}
}
}
})
}), at: 0)
let reblogged = status.reblogged
var reblogImage = UIImage(systemName: "repeat")!
if reblogged {
reblogImage = reblogImage.withTintColor(UIColor(displayP3Red: 1, green: 0.8, blue: 0, alpha: 1), renderingMode: .alwaysOriginal)
}
toggleableSection.insert(createAction(identifier: "reblog", title: reblogged ? "Unreblog" : "Reblog", image: reblogImage, handler: { [weak self] _ in
guard let self = self else { return }
let request = (reblogged ? Status.reblog : Status.unreblog)(status.id)
self.mastodonController?.run(request, completion: { response in
switch response {
case .success(let status, _):
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
case .failure(let error):
if let toastable = self.toastableViewController {
let config = ToastConfiguration(from: error, with: "Error \(reblogged ? "Unr" : "R")eblogging", in: toastable, retryAction: nil)
DispatchQueue.main.async {
toastable.showToast(configuration: config, animated: true)
}
}
}
})
}), at: 1)
}
var actionsSection: [UIAction] = []
if includeReply {
actionsSection.insert(createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { [weak self] (_) in
guard let self = self else { return }
@ -172,7 +228,7 @@ extension MenuActionProvider {
// only allow muting conversations that either current user posted or is participating in (technically, is mentioned, since that's the best we can do)
if status.account.id == account.id || status.mentions.contains(where: { $0.id == account.id }) {
let muted = status.muted
actionsSection.append(createAction(identifier: "mute", title: muted ? "Unmute Conversation" : "Mute Conversation", systemImageName: muted ? "speaker" : "speaker.slash", handler: { [weak self] (_) in
toggleableSection.append(createAction(identifier: "mute", title: muted ? "Unmute Conversation" : "Mute Conversation", systemImageName: muted ? "speaker" : "speaker.slash", handler: { [weak self] (_) in
guard let self = self else { return }
let request = (muted ? Status.unmuteConversation : Status.muteConversation)(status.id)
self.mastodonController?.run(request) { (response) in
@ -195,7 +251,7 @@ extension MenuActionProvider {
if account.id == status.account.id,
mastodonController.instanceFeatures.profilePinnedStatuses {
let pinned = status.pinned ?? false
actionsSection.append(createAction(identifier: "pin", title: pinned ? "Unpin from Profile" : "Pin to Profile", systemImageName: pinned ? "pin.slash" : "pin", handler: { [weak self] (_) in
toggleableSection.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
@ -250,10 +306,18 @@ extension MenuActionProvider {
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID))
return [
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection),
UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: actionsSection),
]
if #available(iOS 16.0, *) {
return [
UIMenu(options: .displayInline, preferredElementSize: .medium, children: toggleableSection + actionsSection),
UIMenu(options: .displayInline, children: shareSection),
]
} else {
return [
UIMenu(options: .displayInline, children: shareSection),
UIMenu(options: .displayInline, children: toggleableSection),
UIMenu(options: .displayInline, children: actionsSection),
]
}
}
func actionsForTrendingLink(card: Card) -> [UIMenuElement] {
@ -286,6 +350,10 @@ extension MenuActionProvider {
} else {
image = nil
}
return createAction(identifier: identifier, title: title, image: image, handler: handler)
}
private func createAction(identifier: String, title: String, image: UIImage?, handler: @escaping UIActionHandler) -> UIAction {
return UIAction(title: title, image: image, identifier: UIAction.Identifier(identifier), discoverabilityTitle: nil, attributes: [], state: .off, handler: handler)
}
@ -296,17 +364,13 @@ extension MenuActionProvider {
}
private func addOpenInNewWindow(actions: inout [UIAction], activity: @escaping @autoclosure () -> NSUserActivity) {
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 UIApplication.shared.supportsMultipleScenes {
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)
}))
}
let options = UIWindowScene.ActivationRequestOptions()
options.preferredPresentationStyle = .automatic
actions.append(UIWindowScene.ActivationAction { (_) in
let activity = activity()
activity.displaysAuxiliaryScene = true
return .init(userActivity: activity, options: options, preview: nil)
})
}
private func followAction(for accountID: String, mastodonController: MastodonController) async -> UIMenuElement? {
@ -357,13 +421,3 @@ extension SFSafariViewController: CustomPreviewPresenting {
presenter.present(self, animated: true)
}
}
private extension UIDeferredMenuElement {
static func uncachedIfPossible(_ elementProvider: @escaping (@escaping ([UIMenuElement]) -> Void) -> Void) -> UIDeferredMenuElement {
if #available(iOS 15.0, *) {
return UIDeferredMenuElement.uncached(elementProvider)
} else {
return UIDeferredMenuElement(elementProvider)
}
}
}

View File

@ -24,6 +24,12 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
self.delegate = self
// this needs to happen in init because EnhancedNavigationViewController expects to be able to look at the titleView
// before the view has necessarily loaded
segmentedControl = UISegmentedControl(items: titles)
segmentedControl.addTarget(self, action: #selector(segmentedControlChanged), for: .valueChanged)
navigationItem.titleView = segmentedControl
}
required init?(coder: NSCoder) {
@ -35,10 +41,6 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
view.backgroundColor = .systemBackground
segmentedControl = UISegmentedControl(items: titles)
segmentedControl.addTarget(self, action: #selector(segmentedControlChanged), for: .valueChanged)
navigationItem.titleView = segmentedControl
segmentedControl.selectedSegmentIndex = 0
selectPage(at: 0, animated: false)

View File

@ -0,0 +1,262 @@
//
// SplitNavigationController.swift
// Tusker
//
// Created by Shadowfacts on 7/1/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
class SplitNavigationController: UIViewController {
private let rootNav = SplitRootNavigationController()
private let secondaryNav = SplitSecondaryNavigationController()
private let separatorView = UIView()
private var constraints: [NSLayoutConstraint] = []
var viewControllers: [UIViewController] {
get {
return rootNav.viewControllers + secondaryNav.viewControllers
}
set {
if newValue.isEmpty {
rootNav.viewControllers = []
secondaryNav.viewControllers = []
} else if canShowSecondaryNav {
var newValue = newValue
rootNav.viewControllers = [newValue.removeFirst()]
secondaryNav.viewControllers = newValue
} else {
rootNav.viewControllers = newValue
secondaryNav.viewControllers = []
}
updateSecondaryNavVisibility()
}
}
/// This property is only valid after the view has been laid out.
private var canShowSecondaryNav: Bool {
// minimum of 360pt for each column
// this allows split navigation on all ipads in portrait w/ sidebar hidden and in landscape (regardless of sidebar)
(viewIfLoaded?.bounds.width ?? 0) >= 720
}
init(rootViewController: UIViewController? = nil) {
super.init(nibName: nil, bundle: nil)
rootNav.showImpl = { [unowned self] vc, sender in
if self.canShowSecondaryNav {
self.setSecondaryViewControllers([vc], animated: true)
// the split nav shouldn't really be reaching down into the inner VCs like this,
// but I can't think of a cleaner way
if let tableVC = sender as? UITableViewController,
let selectedIndexPath = tableVC.tableView.indexPathForSelectedRow {
tableVC.tableView.deselectRow(at: selectedIndexPath, animated: true)
}
} else {
self.rootNav.pushViewController(vc, animated: true)
}
}
secondaryNav.closeSecondaryImpl = { [unowned self] in
self.popToRootViewController(animated: true)
}
if let rootViewController {
rootNav.viewControllers = [rootViewController]
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
embedChild(rootNav, layout: false)
embedChild(secondaryNav, layout: false)
rootNav.view.translatesAutoresizingMaskIntoConstraints = false
secondaryNav.view.translatesAutoresizingMaskIntoConstraints = false
separatorView.backgroundColor = .separator
separatorView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(separatorView)
NSLayoutConstraint.activate([
rootNav.view.topAnchor.constraint(equalTo: view.topAnchor),
rootNav.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
rootNav.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
separatorView.topAnchor.constraint(equalTo: view.topAnchor),
separatorView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
separatorView.leadingAnchor.constraint(equalTo: rootNav.view.trailingAnchor),
separatorView.widthAnchor.constraint(equalToConstant: 0.5),
secondaryNav.view.topAnchor.constraint(equalTo: view.topAnchor),
secondaryNav.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
secondaryNav.view.leadingAnchor.constraint(equalTo: separatorView.trailingAnchor),
])
updateSecondaryNavVisibility()
}
override func show(_ vc: UIViewController, sender: Any?) {
if !canShowSecondaryNav {
rootNav.pushViewController(vc, animated: true)
} else if rootNav.viewControllers.isEmpty {
rootNav.pushViewController(vc, animated: false)
} else {
secondaryNav.pushViewController(vc, animated: true)
}
updateSecondaryNavVisibility()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
if !isLayingOutForAnimation {
updateSecondaryNavVisibility()
}
}
private func updateSecondaryNavVisibility() {
guard isViewLoaded else {
return
}
if canShowSecondaryNav {
if rootNav.viewControllers.count > 1 {
var vcs = rootNav.viewControllers
let root = vcs.removeFirst()
rootNav.viewControllers = [root]
// this shouldn't be necessary since the vcs are removed from their parent vc by setting rootNav.viewControllers
// but it doesn't remove the views from their superview (until the next runloop iteration?)
// so we need to do that ourselves before we can set them on the secondary nav (otherwise it raises an exception)
vcs.forEach { $0.removeViewAndController() }
secondaryNav.viewControllers = vcs
}
} else {
if !secondaryNav.viewControllers.isEmpty {
let firstSecondary = secondaryNav.viewControllers.first!
// remove the left bar button item so that the builtin Back item shows
if firstSecondary.navigationItem.leftBarButtonItem?.tag == ViewTags.splitNavCloseSecondaryButton {
firstSecondary.navigationItem.leftBarButtonItem = nil
}
rootNav.viewControllers.append(contentsOf: secondaryNav.viewControllers)
secondaryNav.viewControllers = []
}
}
setSecondaryVisible(canShowSecondaryNav && !secondaryNav.viewControllers.isEmpty)
}
private func setSecondaryVisible(_ visible: Bool) {
guard isViewLoaded else {
return
}
NSLayoutConstraint.deactivate(constraints)
if visible {
constraints = [
rootNav.view.trailingAnchor.constraint(equalTo: view.centerXAnchor),
secondaryNav.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
]
} else {
constraints = [
rootNav.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
secondaryNav.view.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.5),
]
}
NSLayoutConstraint.activate(constraints)
}
private func setSecondaryViewControllers(_ vcs: [UIViewController], animated: Bool) {
if animated {
if vcs.isEmpty {
popToRootViewController(animated: true)
} else {
let wasVisible = !secondaryNav.viewControllers.isEmpty
secondaryNav.viewControllers = vcs
secondaryNav.view.frame = CGRect(x: view.bounds.width, y: 0, width: view.bounds.width / 2, height: view.bounds.height)
secondaryNav.view.layoutIfNeeded()
if !wasVisible {
let animator = UIViewPropertyAnimator(duration: 0.35, curve: .easeInOut) {
self.updateSecondaryNavVisibility()
self.view.layoutIfNeeded()
}
animator.startAnimation()
}
}
} else {
secondaryNav.viewControllers = vcs
updateSecondaryNavVisibility()
}
}
private var isLayingOutForAnimation = false
func popToRootViewController(animated: Bool) {
if animated {
// we don't update secondaryNav.viewControllers until after the animation is completed
// otherwise the secondary nav's contents disappear immediately, rather than sliding off-screen
let animator = UIViewPropertyAnimator(duration: 0.35, curve: .easeInOut) {
self.isLayingOutForAnimation = true
self.setSecondaryVisible(false)
self.view.layoutIfNeeded()
}
animator.addCompletion { _ in
self.secondaryNav.viewControllers = []
self.isLayingOutForAnimation = false
// self.updateSecondaryNavVisibility()
}
animator.startAnimation()
} else {
self.secondaryNav.viewControllers = []
self.updateSecondaryNavVisibility()
}
}
}
private class SplitRootNavigationController: UINavigationController {
fileprivate var showImpl: ((UIViewController, Any?) -> Void)!
override func show(_ vc: UIViewController, sender: Any?) {
showImpl(vc, sender)
}
}
private class SplitSecondaryNavigationController: EnhancedNavigationViewController {
fileprivate var closeSecondaryImpl: (() -> Void)!
override var viewControllers: [UIViewController] {
didSet {
if let first = viewControllers.first {
configureSecondarySplitCloseButton(for: first)
}
}
}
private func configureSecondarySplitCloseButton(for viewController: UIViewController) {
guard viewController.navigationItem.leftBarButtonItem?.tag != ViewTags.splitNavCloseSecondaryButton else {
return
}
let item = UIBarButtonItem(title: "Close", style: .done, target: self, action: #selector(closeSecondary))
item.tag = ViewTags.splitNavCloseSecondaryButton
viewController.navigationItem.leftBarButtonItem = item
}
@objc private func closeSecondary() {
closeSecondaryImpl()
}
}

View File

@ -10,7 +10,7 @@ import UIKit
// Based on MVCTodo by Dave DeLong: https://github.com/davedelong/MVCTodo/blob/841649dd6aa31bacda3ad7ef9a9a836f66281e50/MVCTodo/Extensions/UIViewController.swift
extension UIViewController {
func embedChild(_ newChild: UIViewController, in container: UIView? = nil) {
func embedChild(_ newChild: UIViewController, in container: UIView? = nil, layout: Bool = true) {
// if the view controller is already a child of something else, remove it
if let oldParent = newChild.parent, oldParent != self {
newChild.beginAppearanceTransition(false, animated: false)
@ -36,7 +36,7 @@ extension UIViewController {
newChild.beginAppearanceTransition(true, animated: false)
addChild(newChild)
newChild.didMove(toParent: self)
targetContainer.embedSubview(newChild.view)
targetContainer.embedSubview(newChild.view, layout: layout)
newChild.endAppearanceTransition()
} else {
// the view controller is already a child
@ -45,7 +45,7 @@ extension UIViewController {
// we don't do the appearance transition stuff here,
// because the vc is already a child, so *presumably*
// that transition stuff has already appened
targetContainer.embedSubview(newChild.view)
targetContainer.embedSubview(newChild.view, layout: layout)
}
}
@ -57,22 +57,25 @@ extension UIViewController {
// Based on MVCTodo by Dave DeLong: https://github.com/davedelong/MVCTodo/blob/841649dd6aa31bacda3ad7ef9a9a836f66281e50/MVCTodo/Extensions/UIView.swift
extension UIView {
func embedSubview(_ subview: UIView) {
func embedSubview(_ subview: UIView, layout: Bool = true) {
if subview.superview == self { return }
if subview.superview != nil {
subview.removeFromSuperview()
}
subview.frame = bounds
addSubview(subview)
NSLayoutConstraint.activate([
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
subview.topAnchor.constraint(equalTo: topAnchor),
subview.bottomAnchor.constraint(equalTo: bottomAnchor)
if layout {
subview.frame = bounds
NSLayoutConstraint.activate([
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
subview.topAnchor.constraint(equalTo: topAnchor),
subview.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
}
func isContainedWithin(_ other: UIView) -> Bool {

View File

@ -8,6 +8,7 @@
import Foundation
import Pachyderm
import UniformTypeIdentifiers
class PostService: ObservableObject {
private let mastodonController: MastodonController
@ -66,15 +67,15 @@ class PostService: ObservableObject {
attachments.reserveCapacity(draft.attachments.count)
for (index, attachment) in draft.attachments.enumerated() {
let data: Data
let mimeType: String
let utType: UTType
do {
(data, mimeType) = try await getData(for: attachment)
(data, utType) = try await getData(for: attachment)
currentStep += 1
} catch let error as CompositionAttachmentData.Error {
throw Error.attachmentData(index: index, cause: error)
}
do {
let uploaded = try await uploadAttachment(data: data, mimeType: mimeType, description: attachment.attachmentDescription)
let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription)
attachments.append(uploaded)
currentStep += 1
} catch let error as Client.Error {
@ -84,7 +85,7 @@ class PostService: ObservableObject {
return attachments
}
private func getData(for attachment: CompositionAttachment) async throws -> (Data, String) {
private func getData(for attachment: CompositionAttachment) async throws -> (Data, UTType) {
return try await withCheckedThrowingContinuation { continuation in
attachment.data.getData { result in
switch result {
@ -97,8 +98,8 @@ class PostService: ObservableObject {
}
}
private func uploadAttachment(data: Data, mimeType: String, description: String?) async throws -> Attachment {
let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file")
private func uploadAttachment(data: Data, utType: UTType, description: String?) async throws -> Attachment {
let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)")
let req = Client.upload(attachment: formAttachment, description: description)
return try await mastodonController.run(req).0
}

View File

@ -89,7 +89,7 @@ extension TuskerNavigationDelegate {
}
func compose(editing draft: Draft) {
if #available(iOS 15.0, *), UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
if 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

20
Tusker/ViewTags.swift Normal file
View File

@ -0,0 +1,20 @@
//
// ViewTags.swift
// Tusker
//
// Created by Shadowfacts on 6/8/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
struct ViewTags {
private init() {}
static let composeVisibilityBarButton = 42001
static let composeLocalOnlyBarButton = 42002
static let navBackBarButton = 42003
static let navForwardBarButton = 42004
static let navEmptyTitleView = 42005
static let splitNavCloseSecondaryButton = 42006
}

View File

@ -23,15 +23,13 @@ class ConfirmLoadMoreTableViewCell: UITableViewCell {
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
}
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
}
}
@ -39,17 +37,13 @@ class ConfirmLoadMoreTableViewCell: UITableViewCell {
super.prepareForReuse()
isLoading = false
if #available(iOS 15.0, *) {
confirmButton.setNeedsUpdateConfiguration()
}
confirmButton.setNeedsUpdateConfiguration()
}
@IBAction func loadMorePressed(_ sender: Any) {
confirmLoadMore?()
if #available(iOS 15.0, *) {
isLoading = true
confirmButton.setNeedsUpdateConfiguration()
}
isLoading = true
confirmButton.setNeedsUpdateConfiguration()
}
}

View File

@ -195,15 +195,38 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
func getLinkAtPoint(_ point: CGPoint) -> (URL, NSRange)? {
let locationInTextContainer = CGPoint(x: point.x - textContainerInset.left, y: point.y - textContainerInset.top)
var partialFraction: CGFloat = 0
let characterIndex = layoutManager.characterIndex(for: locationInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: &partialFraction)
if characterIndex < textStorage.length && partialFraction < 1 {
var range = NSRange()
if let link = textStorage.attribute(.link, at: characterIndex, longestEffectiveRange: &range, in: textStorage.fullRange) as? URL {
return (link, range)
if #available(iOS 16.0, *),
let textLayoutManager {
guard let fragment = textLayoutManager.textLayoutFragment(for: point),
let lineFragment = fragment.textLineFragments.first(where: { lineFragment in
lineFragment.typographicBounds.offsetBy(dx: fragment.layoutFragmentFrame.minX, dy: fragment.layoutFragmentFrame.minY).contains(point)
}) else {
return nil
}
let charIndex = lineFragment.characterIndex(for: point)
var range = NSRange()
guard let link = lineFragment.attributedString.attribute(.link, at: charIndex, longestEffectiveRange: &range, in: lineFragment.attributedString.fullRange) as? URL else {
return nil
}
// lineFragment.attributedString is the NSTextLayoutFragment's string, and so range is in its index space
// but we need to return a range in our whole attributedString's space, so convert it
let textLayoutFragmentStart = textLayoutManager.offset(from: textLayoutManager.documentRange.location, to: fragment.rangeInElement.location)
let rangeInSelf = NSRange(location: range.location + textLayoutFragmentStart, length: range.length)
return (link, rangeInSelf)
} else {
var partialFraction: CGFloat = 0
let characterIndex = layoutManager.characterIndex(for: locationInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: &partialFraction)
guard characterIndex < textStorage.length && partialFraction < 1 else {
return nil
}
var range = NSRange()
guard let link = textStorage.attribute(.link, at: characterIndex, longestEffectiveRange: &range, in: textStorage.fullRange) as? URL else {
return nil
}
return (link, range)
}
return nil
}
func handleLinkTapped(url: URL, text: String) {
@ -247,8 +270,9 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
extension ContentTextView: UITextViewDelegate {
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
// disable the text view's link interactions, we handle tapping links ourself with a gesture recognizer
return false
// generally disable the text view's link interactions, we handle tapping links ourself with a gesture recognizer
// the builtin data detectors use the x-apple-data-detectors scheme, and we allow the text view to handle those itself
return URL.scheme == "x-apple-data-detectors"
}
}
@ -297,8 +321,25 @@ extension ContentTextView: UIContextMenuInteractionDelegate {
// Determine the line rects that the link takes up in the coordinate space of this view.
var rects = [CGRect]()
layoutManager.enumerateEnclosingRects(forGlyphRange: range, withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), in: textContainer) { (rect, stop) in
rects.append(rect)
if #available(iOS 16.0, *),
let textLayoutManager,
let contentManager = textLayoutManager.textContentManager {
// convert from NSRange to NSTextRange
// i have no idea under what circumstances any of these calls could fail
guard let startLoc = contentManager.location(contentManager.documentRange.location, offsetBy: range.location),
let endLoc = contentManager.location(startLoc, offsetBy: range.length),
let textRange = NSTextRange(location: startLoc, end: endLoc) else {
return nil
}
// .standard because i have no idea what the difference is
textLayoutManager.enumerateTextSegments(in: textRange, type: .standard, options: []) { range, rect, float, textContainer in
rects.append(rect)
return true
}
} else {
layoutManager.enumerateEnclosingRects(forGlyphRange: range, withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), in: textContainer) { (rect, stop) in
rects.append(rect)
}
}
// Try to create a snapshot view of this view to disply as the preview.

View File

@ -0,0 +1,80 @@
//
// TrendingHashtagCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 6/29/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class TrendingHashtagCollectionViewCell: UICollectionViewCell {
private let hashtagLabel = UILabel()
private let peopleTodayLabel = UILabel()
private let historyView = TrendHistoryView()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .systemBackground
hashtagLabel.font = .preferredFont(forTextStyle: .title2)
peopleTodayLabel.font = .preferredFont(forTextStyle: .caption1)
let vStack = UIStackView(arrangedSubviews: [
hashtagLabel,
peopleTodayLabel,
])
vStack.axis = .vertical
vStack.alignment = .fill
vStack.distribution = .fill
vStack.spacing = 0
let hStack = UIStackView(arrangedSubviews: [
vStack,
historyView,
])
hStack.axis = .horizontal
hStack.alignment = .center
hStack.distribution = .fill
hStack.spacing = 8
hStack.translatesAutoresizingMaskIntoConstraints = false
addSubview(hStack)
NSLayoutConstraint.activate([
hStack.leadingAnchor.constraint(equalToSystemSpacingAfter: leadingAnchor, multiplier: 1),
trailingAnchor.constraint(equalToSystemSpacingAfter: hStack.trailingAnchor, multiplier: 1),
hStack.topAnchor.constraint(equalTo: topAnchor, constant: 8),
hStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
historyView.widthAnchor.constraint(equalToConstant: 100),
historyView.heightAnchor.constraint(equalToConstant: 44),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func updateUI(hashtag: Hashtag) {
hashtagLabel.text = "#\(hashtag.name)"
historyView.setHistory(hashtag.history)
historyView.isHidden = hashtag.history == nil || hashtag.history!.count < 2
if let history = hashtag.history {
let sorted = history.sorted(by: { $0.day < $1.day })
let lastTwo = sorted[(sorted.count - 2)...]
let accounts = lastTwo.map(\.accounts).reduce(0, +)
let uses = lastTwo.map(\.uses).reduce(0, +)
let format = NSLocalizedString("trending hashtag info", comment: "trending hashtag posts and people")
peopleTodayLabel.text = String.localizedStringWithFormat(format, accounts, uses)
peopleTodayLabel.isHidden = false
} else {
peopleTodayLabel.isHidden = true
}
}
}

View File

@ -1,41 +0,0 @@
//
// TrendingHashtagTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 1/24/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class TrendingHashtagTableViewCell: UITableViewCell {
@IBOutlet weak var hashtagLabel: UILabel!
@IBOutlet weak var peopleTodayLabel: UILabel!
@IBOutlet weak var historyView: TrendHistoryView!
override func awakeFromNib() {
super.awakeFromNib()
}
func updateUI(hashtag: Hashtag) {
hashtagLabel.text = "#\(hashtag.name)"
historyView.setHistory(hashtag.history)
historyView.isHidden = hashtag.history == nil || hashtag.history!.count < 2
if let history = hashtag.history {
let sorted = history.sorted(by: { $0.day < $1.day })
let lastTwo = sorted[(sorted.count - 2)...]
let accounts = lastTwo.map(\.accounts).reduce(0, +)
let uses = lastTwo.map(\.uses).reduce(0, +)
let format = NSLocalizedString("trending hashtag info", comment: "trending hashtag posts and people")
peopleTodayLabel.text = String.localizedStringWithFormat(format, accounts, uses)
peopleTodayLabel.isHidden = false
} else {
peopleTodayLabel.isHidden = true
}
}
}

View File

@ -1,67 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" 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="20020"/>
<capability name="Safe area layout guides" minToolsVersion="9.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" id="KGk-i7-Jjw" customClass="TrendingHashtagTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="66"/>
<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="66"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="tEP-en-vHK">
<rect key="frame" x="16" y="0.0" width="288" height="66"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="iCc-do-llt">
<rect key="frame" x="0.0" y="15" width="180" height="36.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="#hashtag" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="SIS-9e-Paj">
<rect key="frame" x="0.0" y="0.0" width="180" height="23"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle2"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="6 people today" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Kc5-BL-bmu">
<rect key="frame" x="0.0" y="23" width="180" height="13.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Xrw-2v-ybZ" customClass="TrendHistoryView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="188" y="11" width="100" height="44"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="44" id="W4C-uw-zWg"/>
<constraint firstAttribute="width" constant="100" id="XHb-vd-qNk"/>
</constraints>
</view>
</subviews>
</stackView>
</subviews>
<constraints>
<constraint firstItem="tEP-en-vHK" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" id="3Qd-rF-nGk"/>
<constraint firstAttribute="trailingMargin" secondItem="tEP-en-vHK" secondAttribute="trailing" id="Ws6-oZ-9Es"/>
<constraint firstItem="tEP-en-vHK" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" id="if4-Ea-awg"/>
<constraint firstAttribute="bottom" secondItem="tEP-en-vHK" secondAttribute="bottom" id="nTV-Ih-vTj"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<connections>
<outlet property="hashtagLabel" destination="SIS-9e-Paj" id="1UK-Va-3rL"/>
<outlet property="historyView" destination="Xrw-2v-ybZ" id="OIh-K9-gSk"/>
<outlet property="peopleTodayLabel" destination="Kc5-BL-bmu" id="5L8-aO-zt4"/>
</connections>
<point key="canvasLocation" x="132" y="132"/>
</tableViewCell>
</objects>
</document>

View File

@ -171,7 +171,6 @@ class ProfileHeaderView: UIView {
}
private func updateRelationship() {
// todo: mastodonController should never be nil, but ProfileHeaderViews are getting leaked
guard let mastodonController = mastodonController,
let relationship = mastodonController.persistentContainer.relationship(forAccount: accountID) else {
return
@ -181,7 +180,6 @@ class ProfileHeaderView: UIView {
}
@objc private func updateUIForPreferences() {
// todo: mastodonController should never be nil, but ProfileHeaderViews are getting leaked
guard let mastodonController = mastodonController,
// nil if prefs changed before own account is loaded
let accountID = accountID,

View File

@ -12,6 +12,7 @@ import Combine
import AVKit
protocol StatusTableViewCellDelegate: TuskerNavigationDelegate, MenuActionProvider {
// @available(iOS, obsoleted: 16.0)
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell)
}
@ -332,7 +333,11 @@ class BaseStatusTableViewCell: UITableViewCell {
@IBAction func collapseButtonPressed() {
setCollapsed(!collapsed, animated: true)
delegate?.statusCellCollapsedStateChanged(self)
if #available(iOS 16.0, *) {
invalidateIntrinsicContentSize()
} else {
delegate?.statusCellCollapsedStateChanged(self)
}
}
func setCollapsed(_ collapsed: Bool, animated: Bool) {

View File

@ -50,6 +50,10 @@ class ConversationMainStatusTableViewCell: BaseStatusTableViewCell {
]
contentTextView.defaultFont = .systemFont(ofSize: 18)
contentTextView.dataDetectorTypes = [.flightNumber, .address, .shipmentTrackingNumber, .phoneNumber]
if #available(iOS 16.0, *) {
contentTextView.dataDetectorTypes.formUnion([.money, .physicalValue])
}
profileDetailContainerView.addInteraction(UIContextMenuInteraction(delegate: self))

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21179.7" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21169.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"/>
@ -108,7 +108,7 @@
<action selector="collapseButtonPressed" destination="iN0-l3-epB" eventType="touchUpInside" id="JaH-xX-UOD"/>
</connections>
</button>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="waJ-f5-LKv" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="waJ-f5-LKv" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="83" width="277" height="82.5"/>
<string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string>
<color key="textColor" systemColor="labelColor"/>
@ -272,14 +272,14 @@
</view>
</objects>
<resources>
<image name="arrowshape.turn.up.left.fill" catalog="system" width="128" height="106"/>
<image name="chevron.down" catalog="system" width="128" height="72"/>
<image name="arrowshape.turn.up.left.fill" catalog="system" width="128" height="104"/>
<image name="chevron.down" catalog="system" width="128" height="70"/>
<image name="ellipsis" catalog="system" width="128" height="37"/>
<image name="pin.fill" catalog="system" width="119" height="128"/>
<image name="pin.fill" catalog="system" width="116" height="128"/>
<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"/>
<color red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>

View File

@ -8,6 +8,7 @@
import UIKit
import Pachyderm
import WebURLFoundationExtras
class StatusContentTextView: ContentTextView {
@ -27,7 +28,7 @@ class StatusContentTextView: ContentTextView {
let status = mastodonController.persistentContainer.status(for: statusID) {
mention = status.mentions.first { (mention) in
// Mastodon and Pleroma include the @ in the <a> text, GNU Social does not
(text.dropFirst() == mention.username || text == mention.username) && url.host == mention.url.host!
(text.dropFirst() == mention.username || text == mention.username) && url.host == mention.url.host!.serialized
}
} else {
mention = nil
@ -41,7 +42,7 @@ class StatusContentTextView: ContentTextView {
let mastodonController = mastodonController,
let status = mastodonController.persistentContainer.status(for: statusID) {
hashtag = status.hashtags.first { (hashtag) in
hashtag.url == url
URL(hashtag.url) == url
}
} else {
hashtag = nil

View File

@ -24,11 +24,7 @@ class PublicTimelineDescriptionTableViewCell: UITableViewCell {
override func awakeFromNib() {
super.awakeFromNib()
if #available(iOS 15.0, *) {
contentView.backgroundColor = .tintColor
} else {
contentView.backgroundColor = .systemBlue
}
contentView.backgroundColor = .tintColor
}
private func updateLabel() {

View File

@ -42,7 +42,10 @@ class TrendHistoryView: UIView {
private func createLayers() {
guard let history = history,
history.count >= 2 else { return }
history.count >= 2,
!bounds.isEmpty else {
return
}
let maxUses = history.max(by: { $0.uses < $1.uses })!.uses