Compare commits

..

39 Commits

Author SHA1 Message Date
Shadowfacts 1e7bfac13c Bump build number and update changelog 2021-02-15 10:56:36 -05:00
Shadowfacts 6e92633793 Fix crash when adding pinned statuses section during refresh 2021-02-08 18:24:44 -05:00
Shadowfacts e4ff632dcb Fix conversation main status being selectable 2021-02-07 20:16:08 -05:00
Shadowfacts b0ebef2cfd Only show Trending Hashtags and Profile Directory on Mastodon 2021-02-07 19:52:59 -05:00
Shadowfacts bbb8707cb7 Add Profile Directory 2021-02-07 19:39:22 -05:00
Shadowfacts 6a927e4092 Enable drag & drop on account list screen 2021-02-07 11:34:04 -05:00
Shadowfacts 13cdb5d8c7 Add Trending Hashtags to sidebar 2021-02-07 10:45:36 -05:00
Shadowfacts 9f0883d0cb Fix sidebar item getting deselected on add list/instance/hashtag 2021-02-07 10:43:54 -05:00
Shadowfacts eba2e17479 Fix wrong content mode for profile header avatar image 2021-02-06 22:30:28 -05:00
Shadowfacts 5d1c95621b Fix VisualEffectImageButton retain cycle
Button had a menu which had an action which had a closure which strongly
referenced the sourceView which was the button itself.
2021-02-06 15:31:13 -05:00
Shadowfacts 02ba45fa34 Fix crash when opening & closing Preferences after changing account
The old ProfileHeaderView was being leaked, and it was still listening
to the preferencesDidChange notification, but crashing because its
delegate (and therefore MastodonController) had been dealloc'd.
2021-02-06 15:29:35 -05:00
Shadowfacts 9d5c004ec4 Add Trending Hashtags screen 2021-02-06 14:54:35 -05:00
Shadowfacts 37e90229c2 Fix crash when editing list 2021-02-06 14:35:34 -05:00
Shadowfacts 73aceda97f Convert Explore screen to use list-style collection view 2021-02-06 13:48:31 -05:00
Shadowfacts 669d55500a Remove unused pre-iOS 14 code 2021-02-06 13:47:45 -05:00
Shadowfacts f44d127110 Bump deployment target to iOS 14.1 2021-02-05 23:46:31 -05:00
Shadowfacts bcc023a127 Show threads on Conversation screen 2021-01-31 17:42:29 -05:00
Shadowfacts 122cce3bc7 Disable blurhashes in debug builds 2021-01-30 14:15:17 -05:00
Shadowfacts 949162bcab Fix fast account switching animating in wrong direction 2021-01-28 23:20:38 -05:00
Shadowfacts 4ed862120c Add trending hashtags to add saved hashtag controller 2021-01-28 23:20:25 -05:00
Shadowfacts f9411d706b Bump build number and update changelog 2021-01-20 20:55:50 -05:00
Shadowfacts 8f61b0b9a6 Remove old imports 2021-01-20 18:52:16 -05:00
Shadowfacts cdffda5593 Fix crash when profile screen disappears 2021-01-20 18:41:24 -05:00
Shadowfacts d1c45a87e6 Fix low resolution avatars being shown on profile 2021-01-20 18:31:30 -05:00
Shadowfacts 2761c05a01 Remove Cache library 2021-01-20 18:31:14 -05:00
Shadowfacts e7800249af Avoid loading cached data into memory when prefetching 2021-01-18 14:50:56 -05:00
Shadowfacts 2e88b266d9 Prefetch on a background queue to avoid blocking main queue with
CoreData lookups
2021-01-18 14:29:32 -05:00
Shadowfacts 0b008489f7 Add CachingDiskStorage 2021-01-18 14:17:20 -05:00
Shadowfacts de67327f6d Fix ImageCache kicking off extra requests when a completion block was
not provided
2021-01-18 13:46:07 -05:00
Shadowfacts 04a6fe807e Cache scaled images 2021-01-17 13:27:30 -05:00
Shadowfacts 6dee0957ea Remove in-memory caches of most original image data 2021-01-17 11:45:04 -05:00
Shadowfacts c12d2db258 Cache UIImage objects to avoid re-decoding images unnecessarily 2021-01-17 11:28:50 -05:00
Shadowfacts 27b39b79e6 Fix refreshes after no-result refresh not working
Fixes #117
2021-01-13 00:16:33 -05:00
Shadowfacts d7aa3f1617 Fix crash when updating timestamp of removed status 2021-01-12 22:17:30 -05:00
Shadowfacts 69c2faf0e1 Fix crash when user refreshes profile before initial pinned statuses request completes 2021-01-12 22:17:01 -05:00
Shadowfacts 678ed4959b Fix crash upon split view expansion before sidebar VC is loaded 2021-01-12 22:16:20 -05:00
Shadowfacts 0bdcda1b23 Fix secondary windows not respecting theme preference 2021-01-06 19:20:14 -05:00
Shadowfacts 74a30d27e8 Hide keyboard before dismissing windows 2021-01-06 19:16:57 -05:00
Shadowfacts f0e2bb8db6 Fix crash while cancelling prefetching of rows 2020-12-31 23:20:53 -05:00
105 changed files with 2771 additions and 1140 deletions

3
.gitmodules vendored
View File

@ -1,6 +1,3 @@
[submodule "Cache"]
path = Cache
url = git@github.com:hyperoslo/Cache.git
[submodule "Gifu"]
path = Gifu
url = git://github.com/kaishin/Gifu.git

View File

@ -1,5 +1,29 @@
# Changelog
## 2021.1 (17)
The main improvement this build is a complete overhaul of the Conversation screen, along with fixes for a few different crashes.
Features/Improvements:
- Group replies by thread on Conversation screen
- Adding Trending Hashtags and Profile Directory to Explore screen (Mastodon only)
Bugfixes:
- Fix crash when editing List members
- Fix crash when re-opening Preferences after switching accounts
- Fix crash when refreshing profiles in some circumstances
## 2021.1 (16)
This build fixes a number of crashes and significantly improves performance on older devices.
Features/Improvements:
- Significantly improve performance when scrolling through timelines
Bugfixes:
- Fix crash when timeline or profile went offscreen
- Fix crash when refreshing profile too quickly
- iPadOS: Fix secondary windows not respecting theme preference
- Fix refreshes breaking after a refresh which did not return new results
## 2020.1 (15)
There are a whole bunch of new features in this release, in addition to a slew of bugfixes. The big ticket items are multi-window support on iPadOS and fast account switching on iPhone (fast account switching will be supported on iPads in a future build).

1
Cache

@ -1 +0,0 @@
Subproject commit 8c42c575cf28b2ff0e780c9728721e9a8891c92e

View File

@ -323,7 +323,7 @@ public class Client {
return request
}
// MARK: - Trends
// MARK: - Instance
public static func getTrends(limit: Int? = nil) -> Request<[Hashtag]> {
let parameters: [Parameter]
if let limit = limit {
@ -334,6 +334,20 @@ public class Client {
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends", queryParameters: parameters)
}
public static func getFeaturedProfiles(local: Bool, order: DirectoryOrder, offset: Int? = nil, limit: Int? = nil) -> Request<[Account]> {
var parameters = [
"order" => order.rawValue,
"local" => local,
]
if let offset = offset {
parameters.append("offset" => offset)
}
if let limit = limit {
parameters.append("limit" => limit)
}
return Request<[Account]>(method: .get, path: "/api/v1/directory", queryParameters: parameters)
}
}
extension Client {

View File

@ -0,0 +1,14 @@
//
// DirectoryOrder.swift
// Pachyderm
//
// Created by Shadowfacts on 2/6/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import Foundation
public enum DirectoryOrder: String {
case active
case new
}

View File

@ -14,12 +14,15 @@
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450531E22B0097E00100BA2 /* Timline+UI.swift */; };
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04586B4022B2FFB10021BD04 /* PreferencesView.swift */; };
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04586B4222B301470021BD04 /* AppearancePrefsView.swift */; };
0461A3902163CBAE00C0A807 /* Cache.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0461A38F2163CBAE00C0A807 /* Cache.framework */; };
0461A3912163CBAE00C0A807 /* Cache.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0461A38F2163CBAE00C0A807 /* Cache.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04D14BAE22B34A2800642648 /* GalleryViewController.swift */; };
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 /* HashtagHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* HashtagHistoryView.swift */; };
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; };
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60D2B8123844C71001B87A3 /* BaseStatusTableViewCell.swift */; };
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F232442372B005F8713 /* StatusMO.swift */; };
@ -95,7 +98,6 @@
D627943723A552C200D38C68 /* BookmarkStatusActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627943623A552C200D38C68 /* BookmarkStatusActivity.swift */; };
D627943923A553B600D38C68 /* UnbookmarkStatusActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627943823A553B600D38C68 /* UnbookmarkStatusActivity.swift */; };
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */; };
D627943E23A564D400D38C68 /* ExploreViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627943D23A564D400D38C68 /* ExploreViewController.swift */; };
D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */; };
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944923A6AD6100D38C68 /* BookmarksTableViewController.swift */; };
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */; };
@ -112,6 +114,7 @@
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2423217ABF3F005076CC /* NSUserActivity+Extensions.swift */; };
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62D2425217ABF63005076CC /* UserActivityType.swift */; };
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */; };
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; };
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; };
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; };
D63569E023908A8D003DD353 /* StatusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60A4FFB238B726A008AC647 /* StatusState.swift */; };
@ -194,6 +197,11 @@
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */; };
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */; };
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */; };
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; };
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */; };
D693A72C25CF8D15003A14E2 /* DirectoryOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72B25CF8D15003A14E2 /* DirectoryOrder.swift */; };
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */; };
D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */; };
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */; };
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693DE5823FE24300061E07D /* InteractivePushTransition.swift */; };
D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C2E23AC47C3005C403C /* SavedDataManager.swift */; };
@ -225,6 +233,10 @@
D6A5BB2D23BBA9C4003BF21D /* MyProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */; };
D6A5BB2F23BBAC97003BF21D /* DelegatingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */; };
D6A5BB3123BBAD87003BF21D /* JSONResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB3023BBAD87003BF21D /* JSONResponse.swift */; };
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */; };
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10E25B62D2400298D0F /* DiskCache.swift */; };
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11425B62E9700298D0F /* CacheExpiry.swift */; };
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */; };
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */; };
D6ACE1AC240C3BAD004EA8E2 /* Ambassador.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F613023AE99E000F3CFD3 /* Ambassador.framework */; };
D6ACE1AD240C3BAD004EA8E2 /* Embassy.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D65F612D23AE990C00F3CFD3 /* Embassy.framework */; };
@ -265,16 +277,17 @@
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */; };
D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */; };
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */; };
D6C82B5725C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */; };
D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D862139E62700CB5196 /* LargeImageViewController.swift */; };
D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C94D882139E6EC00CB5196 /* AttachmentView.swift */; };
D6C99FC724FACFAB005C74D3 /* ActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */; };
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; };
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; };
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; };
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */; };
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; };
D6D4CC91250D2C3100FCCF8D /* UIAccessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC90250D2C3100FCCF8D /* UIAccessibility.swift */; };
D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; };
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */; };
D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD6212518A200E1C4BB /* Assets.xcassets */; };
@ -288,11 +301,11 @@
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */; };
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0DC8D216EDF1E00369478 /* Previewing.swift */; };
D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */; };
D6E426812532814100C02E1C /* MaybeLazyStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426802532814100C02E1C /* MaybeLazyStack.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 */; };
D6E426B9253382B300C02E1C /* SearchResultType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B8253382B300C02E1C /* SearchResultType.swift */; };
D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = D6E57FA525C26FAB00341037 /* Localizable.stringsdict */; };
D6E6F26321603F8B006A8599 /* CharacterCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26221603F8B006A8599 /* CharacterCounter.swift */; };
D6E6F26521604242006A8599 /* CharacterCounterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E6F26421604242006A8599 /* CharacterCounterTests.swift */; };
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */; };
@ -301,7 +314,6 @@
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */; };
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; };
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F1F84C2193B56E00F5FE67 /* Cache.swift */; };
D6F2E965249E8BFD005846BB /* CrashReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* CrashReporterViewController.swift */; };
D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */; };
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; };
@ -355,7 +367,6 @@
files = (
D61099C12144B0CC00432DC2 /* Pachyderm.framework in Embed Frameworks */,
D6BC874621961F73006163F1 /* Gifu.framework in Embed Frameworks */,
0461A3912163CBAE00C0A807 /* Cache.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
@ -375,6 +386,11 @@
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 /* HashtagHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagHistoryView.swift; sourceTree = "<group>"; };
D60A4FFB238B726A008AC647 /* StatusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusState.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>"; };
@ -451,7 +467,6 @@
D627943623A552C200D38C68 /* BookmarkStatusActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkStatusActivity.swift; sourceTree = "<group>"; };
D627943823A553B600D38C68 /* UnbookmarkStatusActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnbookmarkStatusActivity.swift; sourceTree = "<group>"; };
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigableTableViewCell.swift; sourceTree = "<group>"; };
D627943D23A564D400D38C68 /* ExploreViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreViewController.swift; sourceTree = "<group>"; };
D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BasicTableViewCell.xib; sourceTree = "<group>"; };
D627944923A6AD6100D38C68 /* BookmarksTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksTableViewController.swift; sourceTree = "<group>"; };
D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTimelineViewController.swift; sourceTree = "<group>"; };
@ -468,6 +483,7 @@
D62D2423217ABF3F005076CC /* NSUserActivity+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSUserActivity+Extensions.swift"; sourceTree = "<group>"; };
D62D2425217ABF63005076CC /* UserActivityType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityType.swift; sourceTree = "<group>"; };
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringHelperTests.swift; sourceTree = "<group>"; };
D6311C4F25B3765B00B27539 /* ImageDataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDataCache.swift; sourceTree = "<group>"; };
D6333B362137838300CE884A /* AttributedString+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Helpers.swift"; sourceTree = "<group>"; };
D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; };
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesNavigationController.swift; sourceTree = "<group>"; };
@ -553,6 +569,11 @@
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseEmojiLabel.swift; sourceTree = "<group>"; };
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = "<group>"; };
D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBezierPath+Helpers.swift"; sourceTree = "<group>"; };
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = "<group>"; };
D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDirectoryViewController.swift; sourceTree = "<group>"; };
D693A72B25CF8D15003A14E2 /* DirectoryOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryOrder.swift; sourceTree = "<group>"; };
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedProfileCollectionViewCell.swift; sourceTree = "<group>"; };
D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FeaturedProfileCollectionViewCell.xib; sourceTree = "<group>"; };
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedNavigationViewController.swift; sourceTree = "<group>"; };
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractivePushTransition.swift; sourceTree = "<group>"; };
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedDataManager.swift; sourceTree = "<group>"; };
@ -583,6 +604,10 @@
D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTests.swift; sourceTree = "<group>"; };
D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelegatingResponse.swift; sourceTree = "<group>"; };
D6A5BB3023BBAD87003BF21D /* JSONResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONResponse.swift; sourceTree = "<group>"; };
D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTablePrefetching.swift; sourceTree = "<group>"; };
D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = "<group>"; };
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = "<group>"; };
D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivity+Types.swift"; sourceTree = "<group>"; };
D6AEBB402321642700E5038B /* SendMesasgeActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMesasgeActivity.swift; sourceTree = "<group>"; };
@ -619,16 +644,17 @@
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
D6C7D27C22B6EBF800071952 /* AttachmentsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentsContainerView.swift; sourceTree = "<group>"; };
D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreViewController.swift; sourceTree = "<group>"; };
D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandThreadTableViewCell.swift; sourceTree = "<group>"; };
D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ExpandThreadTableViewCell.xib; sourceTree = "<group>"; };
D6C94D862139E62700CB5196 /* LargeImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageViewController.swift; sourceTree = "<group>"; };
D6C94D882139E6EC00CB5196 /* AttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentView.swift; sourceTree = "<group>"; };
D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorView.swift; sourceTree = "<group>"; };
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = "<group>"; };
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = "<group>"; };
D6D3FDDF24F41B8400FF50A5 /* ComposeContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeContainerView.swift; sourceTree = "<group>"; };
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = "<group>"; };
D6D4CC90250D2C3100FCCF8D /* UIAccessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAccessibility.swift; sourceTree = "<group>"; };
D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = "<group>"; };
D6D4DDCC212518A000E1C4BB /* Tusker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tusker.app; sourceTree = BUILT_PRODUCTS_DIR; };
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@ -648,12 +674,12 @@
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakArray.swift; sourceTree = "<group>"; };
D6E0DC8D216EDF1E00369478 /* Previewing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Previewing.swift; sourceTree = "<group>"; };
D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAutocompleteView.swift; sourceTree = "<group>"; };
D6E426802532814100C02E1C /* MaybeLazyStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaybeLazyStack.swift; sourceTree = "<group>"; };
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcher.swift; sourceTree = "<group>"; };
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuzzyMatcherTests.swift; sourceTree = "<group>"; };
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiImageView.swift; sourceTree = "<group>"; };
D6E426B8253382B300C02E1C /* SearchResultType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultType.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>"; };
D6E6F26221603F8B006A8599 /* CharacterCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounter.swift; sourceTree = "<group>"; };
D6E6F26421604242006A8599 /* CharacterCounterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterCounterTests.swift; sourceTree = "<group>"; };
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextField.swift; sourceTree = "<group>"; };
@ -662,7 +688,6 @@
D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollapseButton.swift; sourceTree = "<group>"; };
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = "<group>"; };
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; };
D6F1F84C2193B56E00F5FE67 /* Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = "<group>"; };
D6F2E963249E8BFD005846BB /* CrashReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReporterViewController.swift; sourceTree = "<group>"; };
D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CrashReporterViewController.xib; sourceTree = "<group>"; };
D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = "<group>"; };
@ -693,7 +718,6 @@
D6B0539F23BD2BA300A066FA /* SheetController in Frameworks */,
D69CCBBF249E6EFD000AF167 /* CrashReporter in Frameworks */,
D6BC874521961F73006163F1 /* Gifu.framework in Frameworks */,
0461A3902163CBAE00C0A807 /* Cache.framework in Frameworks */,
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -804,6 +828,7 @@
D61099E6214561FF00432DC2 /* Attachment.swift */,
D61099E82145658300432DC2 /* Card.swift */,
D61099EA2145661700432DC2 /* ConversationContext.swift */,
D693A72B25CF8D15003A14E2 /* DirectoryOrder.swift */,
D61099E22144C38900432DC2 /* Emoji.swift */,
D61099EC2145664800432DC2 /* Filter.swift */,
D6109A0021456B0800432DC2 /* Hashtag.swift */,
@ -830,6 +855,9 @@
children = (
D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */,
D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */,
D6093FAE25BE0B01004811E6 /* TrendingHashtagTableViewCell.swift */,
D6093FAF25BE0B01004811E6 /* TrendingHashtagTableViewCell.xib */,
D6093FB625BE0CF3004811E6 /* HashtagHistoryView.swift */,
);
path = "Hashtag Cell";
sourceTree = "<group>";
@ -895,9 +923,15 @@
D627943C23A5635D00D38C68 /* Explore */ = {
isa = PBXGroup;
children = (
D627943D23A564D400D38C68 /* ExploreViewController.swift */,
D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */,
D6945C3323AC6431005C403C /* AddSavedHashtagViewController.swift */,
D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */,
D6945C3923AC75E2005C403C /* FindInstanceViewController.swift */,
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */,
D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */,
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */,
D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */,
D600613D25D07E170067FAD6 /* ProfileDirectoryFilterView.swift */,
);
path = Explore;
sourceTree = "<group>";
@ -1025,6 +1059,8 @@
isa = PBXGroup;
children = (
D667E5F42135BCD50057A976 /* ConversationTableViewController.swift */,
D6C82B5425C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift */,
D6C82B5525C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib */,
);
path = Conversation;
sourceTree = "<group>";
@ -1183,7 +1219,6 @@
D67895BB24671E6D00D4CD9E /* PKDrawing+Render.swift */,
D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */,
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */,
D6D4CC90250D2C3100FCCF8D /* UIAccessibility.swift */,
D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */,
D69693F32585941A00F4E116 /* UIWindowSceneDelegate+Close.swift */,
);
@ -1346,10 +1381,8 @@
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */,
D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */,
D6C99FC624FACFAB005C74D3 /* ActivityIndicatorView.swift */,
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */,
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */,
D6E426802532814100C02E1C /* MaybeLazyStack.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
D67C57A721E2649B00C3118B /* Account Detail */,
@ -1385,6 +1418,7 @@
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */,
D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */,
D625E4812588262A0074BB2B /* DraggableTableViewCell.swift */,
D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */,
);
path = Utilities;
sourceTree = "<group>";
@ -1443,6 +1477,7 @@
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */,
D67B506B250B28FF00FAECFB /* Vendor */,
D6F1F84E2193B9BE00F5FE67 /* Caching */,
D6757A7A2157E00100721E32 /* XCallbackURL */,
@ -1491,7 +1526,10 @@
D6F1F84E2193B9BE00F5FE67 /* Caching */ = {
isa = PBXGroup;
children = (
D6F1F84C2193B56E00F5FE67 /* Cache.swift */,
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */,
D6A6C10E25B62D2400298D0F /* DiskCache.swift */,
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */,
D6311C4F25B3765B00B27539 /* ImageDataCache.swift */,
04DACE8D212CC7CC009840C4 /* ImageCache.swift */,
);
path = Caching;
@ -1717,19 +1755,23 @@
buildActionMask = 2147483647;
files = (
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */,
D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */,
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */,
D626493923C0FD0000612E6E /* AllPhotosTableViewCell.xib in Resources */,
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */,
D627FF7D217E958900CC0648 /* DraftTableViewCell.xib in Resources */,
D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */,
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */,
D6C82B5725C5F3F20017F1E6 /* ExpandThreadTableViewCell.xib in Resources */,
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */,
D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */,
D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */,
D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */,
D663625D2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib in Resources */,
D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */,
D6969EA4240DD28D002843CE /* UnknownNotificationTableViewCell.xib in Resources */,
D6289E84217B795D0003D1D7 /* LargeImageViewController.xib in Resources */,
D6093FB125BE0B01004811E6 /* TrendingHashtagTableViewCell.xib in Resources */,
D627FF79217E950100CC0648 /* DraftsTableViewController.xib in Resources */,
D626493323BD751600612E6E /* ShowCameraCollectionViewCell.xib in Resources */,
D626493D23C1000300612E6E /* AlbumTableViewCell.xib in Resources */,
@ -1808,6 +1850,7 @@
D61099D02144B2D700432DC2 /* Method.swift in Sources */,
D6E6F26321603F8B006A8599 /* CharacterCounter.swift in Sources */,
D61099FB214569F600432DC2 /* Report.swift in Sources */,
D693A72C25CF8D15003A14E2 /* DirectoryOrder.swift in Sources */,
D61099F92145698900432DC2 /* Relationship.swift in Sources */,
D61099E12144C1DC00432DC2 /* Account.swift in Sources */,
D61099E92145658300432DC2 /* Card.swift in Sources */,
@ -1850,6 +1893,7 @@
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 */,
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
@ -1857,6 +1901,7 @@
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */,
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */,
D6757A822157E8FA00721E32 /* XCBSession.swift in Sources */,
D6093FB725BE0CF3004811E6 /* HashtagHistoryView.swift in Sources */,
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */,
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */,
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */,
@ -1877,7 +1922,6 @@
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */,
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */,
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */,
D6C99FC724FACFAB005C74D3 /* ActivityIndicatorView.swift in Sources */,
D6B22A0F2560D52D004D82EF /* TabbedPageViewController.swift in Sources */,
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */,
0411610022B442870030A9B7 /* LoadingLargeImageViewController.swift in Sources */,
@ -1889,6 +1933,7 @@
D627943723A552C200D38C68 /* BookmarkStatusActivity.swift in Sources */,
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */,
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */,
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */,
D620483623D38075008A63EF /* ContentTextView.swift in Sources */,
@ -1910,7 +1955,7 @@
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */,
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */,
D6E426812532814100C02E1C /* MaybeLazyStack.swift in Sources */,
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */,
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */,
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */,
@ -1933,6 +1978,7 @@
D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */,
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */,
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
D627FF7F217E95E000CC0648 /* DraftTableViewCell.swift in Sources */,
D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */,
@ -1941,10 +1987,13 @@
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */,
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */,
D679C09F215850EF00DA27FE /* XCBActions.swift in Sources */,
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */,
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */,
D6093FB025BE0B01004811E6 /* TrendingHashtagTableViewCell.swift in Sources */,
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */,
D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */,
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */,
@ -1970,8 +2019,6 @@
D6B053AE23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift in Sources */,
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */,
D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */,
D6D4CC91250D2C3100FCCF8D /* UIAccessibility.swift in Sources */,
D627943E23A564D400D38C68 /* ExploreViewController.swift in Sources */,
D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */,
D622757A24EE21D900B82A16 /* ComposeAttachmentRow.swift in Sources */,
D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */,
@ -1983,6 +2030,7 @@
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
@ -2009,6 +2057,7 @@
D670F8B62537DC890046588A /* EmojiPickerWrapper.swift in Sources */,
D6B81F442560390300F6E31D /* MenuController.swift in Sources */,
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */,
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */,
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
@ -2018,6 +2067,7 @@
D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */,
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */,
D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */,
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */,
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
@ -2034,7 +2084,6 @@
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */,
D65234D325618EFA001AF9CF /* TimelineTableViewController.swift in Sources */,
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */,
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
0427037C22B316B9000D31B6 /* SilentActionPrefs.swift in Sources */,
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
@ -2043,6 +2092,7 @@
D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */,
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */,
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */,
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */,
D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */,
D6757A7E2157E02600721E32 /* XCBRequestSpec.swift in Sources */,
D677284E24ECC01D00C732D3 /* Draft.swift in Sources */,
@ -2052,6 +2102,8 @@
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */,
D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */,
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
@ -2121,6 +2173,14 @@
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */ = {
isa = PBXVariantGroup;
children = (
D6E57FA425C26FAB00341037 /* en */,
);
name = Localizable.stringsdict;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
@ -2275,7 +2335,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@ -2331,7 +2391,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
@ -2349,16 +2409,16 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 15;
CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2020.1;
MARKETING_VERSION = 2021.1;
OTHER_LDFLAGS = "";
"OTHER_SWIFT_FLAGS[sdk=iphone*14*]" = "$(inherited) -D SDK_IOS_14";
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker;
@ -2378,16 +2438,16 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 15;
CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2020.1;
MARKETING_VERSION = 2021.1;
"OTHER_SWIFT_FLAGS[sdk=iphone*14*]" = "$(inherited) -D SDK_IOS_14";
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker;
PRODUCT_NAME = "$(TARGET_NAME)";

View File

@ -7,9 +7,6 @@
<FileRef
location = "group:BlankSlate.xcappdata">
</FileRef>
<FileRef
location = "group:Cache/Cache.xcodeproj">
</FileRef>
<FileRef
location = "group:Gifu/Gifu.xcodeproj">
</FileRef>

View File

@ -29,7 +29,7 @@ class AccountActivityItemSource: NSObject, UIActivityItemSource {
metadata.originalURL = account.url
metadata.url = account.url
metadata.title = "\(account.displayName) (@\(account.username)@\(account.url.host!)"
if let data = ImageCache.avatars.get(account.avatar),
if let data = ImageCache.avatars.getData(account.avatar),
let image = UIImage(data: data) {
metadata.iconProvider = NSItemProvider(object: image)
}

View File

@ -32,7 +32,7 @@ class StatusActivityItemSource: NSObject, UIActivityItemSource {
let doc = try! SwiftSoup.parse(status.content)
let content = try! doc.text()
metadata.title = "\(status.account.displayName): \"\(content)\""
if let data = ImageCache.avatars.get(status.account.avatar),
if let data = ImageCache.avatars.getData(status.account.avatar),
let image = UIImage(data: data) {
metadata.iconProvider = NSItemProvider(object: image)
}

View File

@ -56,6 +56,9 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
window = UIWindow(windowScene: windowScene)
window!.rootViewController = nav
window!.makeKeyAndVisible()
NotificationCenter.default.addObserver(self, selector: #selector(themePrefChanged), name: .themePreferenceChanged, object: nil)
themePrefChanged()
}
func sceneWillResignActive(_ scene: UIScene) {
@ -109,4 +112,8 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
@objc private func close() {
closeWindow()
}
@objc private func themePrefChanged() {
window?.overrideUserInterfaceStyle = Preferences.shared.theme
}
}

View File

@ -1,62 +0,0 @@
//
// Cache.swift
// Tusker
//
// Created by Shadowfacts on 11/7/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
import Cache
/// Wrapper around Cache library that provides an API for transparently using any storage type
enum Cache<T> {
case memory(MemoryStorage<T>)
case disk(DiskStorage<T>)
case hybrid(HybridStorage<T>)
@available(*, deprecated, message: "disk-based caches synchronously interact with the file system. Avoid using if possible.")
func existsObject(forKey key: String) throws -> Bool {
switch self {
case let .memory(memory):
return try memory.existsObject(forKey: key)
case let .disk(disk):
return try disk.existsObject(forKey: key)
case let .hybrid(hybrid):
return try hybrid.existsObject(forKey: key)
}
}
func object(forKey key: String) throws -> T {
switch self {
case let .memory(memory):
return try memory.object(forKey: key)
case let .disk(disk):
return try disk.object(forKey: key)
case let .hybrid(hybrid):
return try hybrid.object(forKey: key)
}
}
func setObject(_ object: T, forKey key: String, expiry: Expiry? = nil) throws {
switch self {
case let .memory(memory):
memory.setObject(object, forKey: key, expiry: expiry)
case let .disk(disk):
try disk.setObject(object, forKey: key, expiry: expiry)
case let .hybrid(hybrid):
try hybrid.setObject(object, forKey: key, expiry: expiry)
}
}
func removeAll() throws {
switch self {
case let .memory(memory):
memory.removeAll()
case let .disk(disk):
try disk.removeAll()
case let .hybrid(hybrid):
try hybrid.removeAll()
}
}
}

View File

@ -0,0 +1,30 @@
//
// CacheExpiry.swift
// Tusker
//
// Created by Shadowfacts on 1/18/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import Foundation
enum CacheExpiry {
case never
case seconds(TimeInterval)
case date(Date)
var date: Date {
switch self {
case .never:
return .distantFuture
case let .seconds(seconds):
return Date().addingTimeInterval(seconds)
case let .date(date):
return date
}
}
var isExpired: Bool {
return date.timeIntervalSinceNow < 0
}
}

View File

@ -0,0 +1,143 @@
//
// DiskCache.swift
// Tusker
//
// Created by Shadowfacts on 1/18/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import Foundation
import CryptoKit
struct DiskCacheTransformer<T> {
let toData: (T) throws -> Data
let fromData: (Data) throws -> T
}
class DiskCache<T> {
let fileManager: FileManager
let path: String
let defaultExpiry: CacheExpiry
let transformer: DiskCacheTransformer<T>
private var fileStates = [String: FileState]()
init(name: String, defaultExpiry: CacheExpiry, transformer: DiskCacheTransformer<T>, fileManager: FileManager = .default) throws {
self.defaultExpiry = defaultExpiry
self.transformer = transformer
self.fileManager = fileManager
let cacheDir = try fileManager.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
self.path = cacheDir.appendingPathComponent(name, isDirectory: true).path
try createDirectory()
}
private func createDirectory() throws {
if !fileManager.fileExists(atPath: path) {
try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil)
}
}
private func makeFileName(for key: String) -> String {
let ext = (key as NSString).pathExtension
let digest = Insecure.MD5.hash(data: key.data(using: .utf8)!)
let hash = digest.map { String($0, radix: 16) }.joined()
if ext.isEmpty {
return hash
} else {
return "\(hash).\(ext)"
}
}
private func makeFilePath(for key: String) -> String {
return (path as NSString).appendingPathComponent(makeFileName(for: key))
}
private func fileState(forKey key: String) -> FileState {
return fileStates[key] ?? .unknown
}
func setObject(_ object: T, forKey key: String) throws {
let data = try transformer.toData(object)
let path = makeFilePath(for: key)
guard fileManager.createFile(atPath: path, contents: data, attributes: [.modificationDate: defaultExpiry.date]) else {
throw Error.couldNotCreateFile
}
fileStates[key] = .exists
}
func removeObject(forKey key: String) throws {
let path = makeFilePath(for: key)
try fileManager.removeItem(atPath: path)
fileStates[key] = .doesNotExist
}
func existsObject(forKey key: String) throws -> Bool {
switch fileState(forKey: key) {
case .exists:
return true
case .doesNotExist:
return false
case .unknown:
let path = makeFilePath(for: key)
guard fileManager.fileExists(atPath: path) else {
return false
}
let attributes = try fileManager.attributesOfItem(atPath: path)
guard let date = attributes[.modificationDate] as? Date else {
throw Error.malformedFileAttributes
}
return date.timeIntervalSinceNow >= 0
}
}
func object(forKey key: String) throws -> T {
let path = makeFilePath(for: key)
let attributes = try fileManager.attributesOfItem(atPath: path)
guard let date = attributes[.modificationDate] as? Date else {
throw Error.malformedFileAttributes
}
guard date.timeIntervalSinceNow >= 0 else {
try fileManager.removeItem(atPath: path)
fileStates[key] = .doesNotExist
throw Error.expired
}
let data = try Data(contentsOf: URL(fileURLWithPath: path, isDirectory: false))
let object = try transformer.fromData(data)
return object
}
func removeAll() throws {
try fileManager.removeItem(atPath: path)
try createDirectory()
fileStates.removeAll()
}
}
extension DiskCache {
enum Error: Swift.Error {
case malformedFileAttributes
case couldNotCreateFile
case expired
}
}
extension DiskCache {
enum FileState {
case exists, doesNotExist, unknown
}
}
extension DiskCache where T == Data {
convenience init(name: String, defaultExpiry: CacheExpiry) throws {
try self.init(name: name, defaultExpiry: defaultExpiry, transformer: DiskCacheTransformer(toData: { $0 }, fromData: { $0 }))
}
}

View File

@ -7,11 +7,10 @@
//
import UIKit
import Cache
class ImageCache {
static let avatars = ImageCache(name: "Avatars", memoryExpiry: .seconds(60 * 60), diskExpiry: .seconds(60 * 60 * 24))
static let avatars = ImageCache(name: "Avatars", memoryExpiry: .seconds(60 * 60), diskExpiry: .seconds(60 * 60 * 24), desiredSize: CGSize(width: 50, height: 50))
static let headers = ImageCache(name: "Headers", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60))
static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2))
static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60))
@ -22,51 +21,36 @@ class ImageCache {
private static let disableCaching = false
#endif
private let cache: Cache<Data>
private let cache: ImageDataCache
private var groups = MultiThreadDictionary<URL, RequestGroup>(name: "ImageCache request groups")
private var backgroundQueue = DispatchQueue(label: "ImageCache completion queue", qos: .default)
init(name: String, memoryExpiry expiry: Expiry) {
let storage = MemoryStorage<Data>(config: MemoryConfig(expiry: expiry))
self.cache = .memory(storage)
init(name: String, memoryExpiry: CacheExpiry, diskExpiry: CacheExpiry? = nil, desiredSize: CGSize? = nil) {
// todo: might not always want to use UIScreen.main for this, e.g. Catalyst?
let pixelSize = desiredSize?.applying(.init(scaleX: UIScreen.main.scale, y: UIScreen.main.scale))
self.cache = ImageDataCache(name: name, memoryExpiry: memoryExpiry, diskExpiry: diskExpiry, storeOriginalDataInMemory: diskExpiry == nil, desiredPixelSize: pixelSize)
}
init(name: String, diskExpiry expiry: Expiry) {
let storage = try! DiskStorage<Data>(config: DiskConfig(name: name, expiry: expiry), transformer: TransformerFactory.forData())
self.cache = .disk(storage)
}
init(name: String, memoryExpiry: Expiry, diskExpiry: Expiry) {
let memory = MemoryStorage<Data>(config: MemoryConfig(expiry: memoryExpiry))
let disk = try! DiskStorage<Data>(config: DiskConfig(name: name, expiry: diskExpiry), transformer: TransformerFactory.forData())
self.cache = .hybrid(HybridStorage(memoryStorage: memory, diskStorage: disk))
}
func get(_ url: URL, completion: ((Data?) -> Void)?) -> Request? {
func get(_ url: URL, loadOriginal: Bool = false, completion: ((Data?, UIImage?) -> Void)?) -> Request? {
let key = url.absoluteString
if !ImageCache.disableCaching,
// todo: calling object(forKey: key) does disk I/O and this method is often called from the main thread
// in performance sensitive paths. a nice optimization to DiskStorage would be adding an internal cache
// of the state (unknown/exists/does not exist) of whether or not objects exist on disk so that the slow, disk I/O
// path can be avoided most of the time
let data = try? cache.object(forKey: key) {
let entry = try? cache.get(key, loadOriginal: loadOriginal) {
if let completion = completion {
backgroundQueue.async {
completion?(data)
completion(entry.data, entry.image)
}
}
return nil
} else {
if let completion = completion, let group = groups[url] {
if let group = groups[url] {
if let completion = completion {
return group.addCallback(completion)
}
return nil
} else {
let group = RequestGroup(url: url) { (data) in
if let data = data {
try? self.cache.setObject(data, forKey: key)
}
self.groups.removeValueWithoutReturning(forKey: url)
}
groups[url] = group
let group = createGroup(url: url)
let request = group.addCallback(completion)
group.run()
return request
@ -74,8 +58,34 @@ class ImageCache {
}
}
func get(_ url: URL) -> Data? {
return try? cache.object(forKey: url.absoluteString)
func fetchIfNotCached(_ url: URL) {
// if caching is disabled, don't bother fetching since nothing will be done with the result
guard !ImageCache.disableCaching else { return }
if !((try? cache.has(url.absoluteString)) ?? false),
!groups.contains(key: url) {
let group = createGroup(url: url)
group.run()
}
}
private func createGroup(url: URL) -> RequestGroup {
let group = RequestGroup(url: url) { (data, image) in
if let data = data {
try? self.cache.set(url.absoluteString, data: data, image: image)
}
self.groups.removeValueWithoutReturning(forKey: url)
}
groups[url] = group
return group
}
func getData(_ url: URL) -> Data? {
return try? cache.getData(url.absoluteString)
}
func get(_ url: URL, loadOriginal: Bool = false) -> ImageDataCache.Entry? {
return try? cache.get(url.absoluteString, loadOriginal: loadOriginal)
}
func cancelWithoutCallback(_ url: URL) {
@ -88,11 +98,11 @@ class ImageCache {
private class RequestGroup {
let url: URL
private let onFinished: (Data?) -> Void
private let onFinished: (Data?, UIImage?) -> Void
private var task: URLSessionDataTask?
private var requests = [Request]()
init(url: URL, onFinished: @escaping (Data?) -> Void) {
init(url: URL, onFinished: @escaping (Data?, UIImage?) -> Void) {
self.url = url
self.onFinished = onFinished
}
@ -116,7 +126,7 @@ class ImageCache {
task?.priority = max(1.0, URLSessionTask.defaultPriority + 0.1 * Float(requests.filter { !$0.cancelled }.count))
}
func addCallback(_ completion: ((Data?) -> Void)?) -> Request {
func addCallback(_ completion: ((Data?, UIImage?) -> Void)?) -> Request {
let request = Request(callback: completion)
requests.append(request)
updatePriority()
@ -141,21 +151,24 @@ class ImageCache {
}
func complete(with data: Data?) {
let image = data != nil ? UIImage(data: data!) : nil
requests.filter { !$0.cancelled }.forEach {
if let callback = $0.callback {
callback(data)
callback(data, image)
}
}
self.onFinished(data)
self.onFinished(data, image)
}
}
class Request {
private weak var group: RequestGroup?
private(set) var callback: ((Data?) -> Void)?
private(set) var callback: ((Data?, UIImage?) -> Void)?
private(set) var cancelled: Bool = false
init(callback: ((Data?) -> Void)?) {
init(callback: ((Data?, UIImage?) -> Void)?) {
self.callback = callback
}

View File

@ -0,0 +1,108 @@
//
// ImageDataCache.swift
// Tusker
//
// Created by Shadowfacts on 1/16/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
class ImageDataCache {
private let memory: MemoryCache<Entry>
private let disk: DiskCache<Data>?
private let storeOriginalDataInMemory: Bool
private let desiredPixelSize: CGSize?
init(name: String, memoryExpiry: CacheExpiry, diskExpiry: CacheExpiry?, storeOriginalDataInMemory: Bool, desiredPixelSize: CGSize?) {
self.memory = MemoryCache(defaultExpiry: memoryExpiry)
if let diskExpiry = diskExpiry {
self.disk = try! DiskCache(name: name, defaultExpiry: diskExpiry)
} else {
self.disk = nil
}
self.storeOriginalDataInMemory = storeOriginalDataInMemory
self.desiredPixelSize = desiredPixelSize
}
func has(_ key: String) throws -> Bool {
if memory.existsObject(forKey: key) {
return true
} else if let disk = self.disk,
try disk.existsObject(forKey: key) {
return true
} else {
return false
}
}
func get(_ key: String, loadOriginal: Bool) throws -> Entry? {
if storeOriginalDataInMemory || !loadOriginal,
let memoryEntry = try? memory.object(forKey: key) {
return memoryEntry
} else if let disk = self.disk,
let data = try? disk.object(forKey: key),
let image = UIImage(data: data) {
return Entry(data: data, image: image)
} else {
return nil
}
}
func getImage(_ key: String) throws -> UIImage? {
return try get(key, loadOriginal: false)?.image
}
func getData(_ key: String) throws -> Data? {
return try get(key, loadOriginal: false)?.data
}
func set(_ key: String, data: Data, image: UIImage?) throws {
guard let image = scaleImageIfDesired(data: data) ?? image else { return }
let entry = Entry(data: storeOriginalDataInMemory ? data : nil, image: image)
memory.setObject(entry, forKey: key)
if let disk = self.disk {
try disk.setObject(data, forKey: key)
}
}
func removeAll() throws {
memory.removeAll()
try? disk?.removeAll()
}
private func scaleImageIfDesired(data: Data) -> UIImage? {
guard let desiredPixelSize = desiredPixelSize,
let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary) else {
return nil
}
let maxDimension = max(desiredPixelSize.width, desiredPixelSize.height)
let downsampleOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimension
] as CFDictionary
if let downsampled = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) {
return UIImage(cgImage: downsampled)
} else {
return nil
}
}
}
extension ImageDataCache {
struct Entry {
let data: Data?
let image: UIImage
}
}

View File

@ -0,0 +1,69 @@
//
// MemoryCache.swift
// Tusker
//
// Created by Shadowfacts on 1/18/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import Foundation
class MemoryCache<T> {
private let cache = NSCache<NSString, Entry>()
private let defaultExpiry: CacheExpiry
init(defaultExpiry: CacheExpiry) {
self.defaultExpiry = defaultExpiry
}
func setObject(_ object: T, forKey key: String) {
let entry = Entry(expiresAt: defaultExpiry.date, object: object)
cache.setObject(entry, forKey: key as NSString)
}
func removeObject(forKey key: String) {
cache.removeObject(forKey: key as NSString)
}
func existsObject(forKey key: String) -> Bool {
return cache.object(forKey: key as NSString) != nil
}
func object(forKey key: String) throws -> T {
guard let entry = cache.object(forKey: key as NSString) else {
throw Error.notFound
}
guard entry.expiresAt.timeIntervalSinceNow >= 0 else {
cache.removeObject(forKey: key as NSString)
throw Error.expired
}
return entry.object
}
func removeAll() {
cache.removeAllObjects()
}
}
extension MemoryCache {
enum Error: Swift.Error {
case notFound
case expired
}
}
extension MemoryCache {
class Entry {
let expiresAt: Date
let object: T
init(expiresAt: Date, object: T) {
self.expiresAt = expiresAt
self.object = object
}
}
}

View File

@ -42,6 +42,9 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
window = UIWindow(windowScene: windowScene)
window!.rootViewController = nav
window!.makeKeyAndVisible()
NotificationCenter.default.addObserver(self, selector: #selector(themePrefChanged), name: .themePreferenceChanged, object: nil)
themePrefChanged()
}
func sceneWillResignActive(_ scene: UIScene) {
@ -58,6 +61,10 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
return scene.userActivity
}
@objc private func themePrefChanged() {
window?.overrideUserInterfaceStyle = Preferences.shared.theme
}
}
extension ComposeSceneDelegate: ComposeHostingControllerDelegate {

View File

@ -115,6 +115,7 @@ class MastodonController: ObservableObject {
}
}
// todo: this should dedup requests
func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
if let instance = self.instance {
completion?(instance)

View File

@ -11,20 +11,13 @@ import UIKit
struct MenuController {
static let composeCommand: UIKeyCommand = {
let selector: Selector
if #available(iOS 14.0, *) {
selector = #selector(MainSplitViewController.presentCompose)
} else {
selector = #selector(MainTabBarViewController.presentCompose)
}
return UIKeyCommand(title: "Compose", action: selector, input: "n", modifierFlags: .command)
return UIKeyCommand(title: "Compose", action: #selector(MainSplitViewController.presentCompose), input: "n", modifierFlags: .command)
}()
static func refreshCommand(discoverabilityTitle: String?) -> UIKeyCommand {
return UIKeyCommand(title: "Refresh", action: #selector(RefreshableViewController.refresh), input: "r", modifierFlags: .command, discoverabilityTitle: discoverabilityTitle)
}
@available(iOS 14.0, *)
static func sidebarCommand(item: MainSidebarViewController.Item, command: String) -> UIKeyCommand {
let data: Any
if case let .tab(tab) = item {
@ -46,7 +39,6 @@ struct MenuController {
)
}
@available(iOS 14.0, *)
static let sidebarItemKeyCommands: [UIKeyCommand] = [
sidebarCommand(item: .tab(.timelines), command: "1"),
sidebarCommand(item: .tab(.notifications), command: "2"),
@ -92,25 +84,18 @@ struct MenuController {
}
private static func buildSidebarShortcuts() -> UIMenu {
let children: [UIMenuElement]
if #available(iOS 14.0, *) {
children = sidebarItemKeyCommands
} else {
children = []
}
return UIMenu(
title: "",
image: nil,
identifier: nil,
options: .displayInline,
children: children
children: sidebarItemKeyCommands
)
}
}
extension MenuController {
@available(iOS 14.0, *)
class SidebarItem: NSObject, NSCopying {
let item: MainSidebarViewController.Item

View File

@ -24,6 +24,12 @@ class MastodonCachePersistentStore: NSPersistentContainer {
return context
}()
private(set) lazy var prefetchBackgroundContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.parent = self.viewContext
return context
}()
let statusSubject = PassthroughSubject<String, Never>()
let accountSubject = PassthroughSubject<String, Never>()
let relationshipSubject = PassthroughSubject<String, Never>()

View File

@ -1,21 +0,0 @@
//
// UIAccessibility.swift
// Tusker
//
// Created by Shadowfacts on 9/12/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
extension UIAccessibility {
static var prefersCrossFadeTransitionsBackwardsCompat: Bool {
if #available(iOS 14.0, *) {
return prefersCrossFadeTransitions
} else {
return isReduceMotionEnabled
}
}
}

View File

@ -12,6 +12,13 @@ extension UIWindowSceneDelegate {
func closeWindow(animation: UIWindowScene.DismissalAnimation = .standard, errorHandler: ((Error) -> Void)? = nil) {
guard let session = self.window??.windowScene?.session else { return }
// Hide the keyboard before dismissing window.
// Calling resignFirstResponder() on the window does not work (always returns false).
// Using UIApplication.shared.sendAction(#selector(resignFirstResponder), to: nil, from: nil, for: nil)
// may not work as desired if the window with focus is not the one being dismissed (in which case it's okay
// if the keyboard remains visible).
window??.endEditing(true)
let options = UIWindowSceneDestructionRequestOptions()
options.windowDismissalAnimation = animation
UIApplication.shared.requestSceneSessionDestruction(session, options: options, errorHandler: errorHandler)

View File

@ -14,6 +14,15 @@ struct ImageGrayscalifier {
private static let context = CIContext()
private static let cache = NSCache<NSURL, UIImage>()
static func convertIfNecessary(url: URL?, image: UIImage) -> UIImage? {
if Preferences.shared.grayscaleImages,
let source = image.cgImage {
return convert(url: url, cgImage: source)
} else {
return image
}
}
static func convert(url: URL?, data: Data) -> UIImage? {
if let url = url,
let cached = cache.object(forKey: url as NSURL) {

View File

@ -142,7 +142,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
}
func activateAccount(_ account: LocalData.UserAccountInfo, animated: Bool) {
let oldMostRecentAccount = LocalData.shared.mostRecentAccountID
LocalData.shared.setMostRecentAccount(account)
window!.windowScene!.session.mastodonController = MastodonController.getForAccount(account)
@ -150,7 +150,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
let direction: AccountSwitchingContainerViewController.AnimationDirection
if animated,
let oldIndex = LocalData.shared.accounts.firstIndex(where: { $0.id == LocalData.shared.mostRecentAccountID }),
let oldIndex = LocalData.shared.accounts.firstIndex(where: { $0.id == oldMostRecentAccount }),
let newIndex = LocalData.shared.accounts.firstIndex(of: account) {
direction = newIndex > oldIndex ? .upwards : .downwards
} else {
@ -176,11 +176,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
mastodonController.getOwnAccount()
mastodonController.getOwnInstance()
if #available(iOS 14.0, *) {
return MainSplitViewController(mastodonController: mastodonController)
} else {
return MainTabBarViewController(mastodonController: mastodonController)
}
}
func createOnboardingUI() -> UIViewController {

View File

@ -47,4 +47,12 @@ class MultiThreadDictionary<Key: Hashable, Value> {
}
return value
}
func contains(key: Key) -> Bool {
var value: Bool!
queue.sync {
value = dict.keys.contains(key)
}
return value
}
}

View File

@ -30,6 +30,8 @@ class AccountListTableViewController: EnhancedTableViewController {
override func viewDidLoad() {
super.viewDidLoad()
dragEnabled = true
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell)
tableView.rowHeight = UITableView.automaticDimension

View File

@ -25,7 +25,7 @@ class AttachmentPreviewViewController: UIViewController {
}
override func loadView() {
if let data = ImageCache.attachments.get(attachment.url),
if let data = ImageCache.attachments.getData(attachment.url),
let image = UIImage(data: data) {
let imageView: UIImageView
if attachment.url.pathExtension == "gif" {

View File

@ -44,7 +44,7 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
var animationGifData: Data? {
let attachment = attachments[currentIndex]
if attachment.url.pathExtension == "gif" {
return ImageCache.attachments.get(attachment.url)
return ImageCache.attachments.getData(attachment.url)
} else {
return nil
}

View File

@ -162,24 +162,19 @@ extension BookmarksTableViewController: StatusTableViewCellDelegate {
}
}
extension BookmarksTableViewController: UITableViewDataSourcePrefetching {
extension BookmarksTableViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { continue }
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments where attachment.kind == .image {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
}
}
let ids = indexPaths.map { statuses[$0.row].id }
prefetchStatuses(with: ids)
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { continue }
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments where attachment.kind == .image {
ImageCache.attachments.cancelWithoutCallback(attachment.url)
}
}
let ids: [String] = indexPaths.compactMap {
guard $0.row < statuses.count else {
return nil
}
return statuses[$0.row].id
}
cancelPrefetchingStatuses(with: ids)
}
}

View File

@ -34,25 +34,11 @@ struct ComposeAttachmentRow: View {
.contextMenu {
if case .drawing(_) = attachment.data {
Button(action: self.editDrawing) {
if #available(iOS 14.0, *) {
Label("Edit Drawing", systemImage: "hand.draw")
} else {
HStack {
Text("Edit Drawing")
Image(systemName: "hand.draw")
}
}
}
} else if attachment.data.type == .image {
Button(action: self.recognizeText) {
if #available(iOS 14.0, *) {
Label("Recognize Text", systemImage: "doc.text.viewfinder")
} else {
HStack {
Text("Recognize Text")
Image(systemName: "doc.text.viewfinder")
}
}
}
}
}
@ -65,11 +51,7 @@ struct ComposeAttachmentRow: View {
.fontSize(17)
case .recognizingText:
if #available(iOS 14.0, *) {
ProgressView()
} else {
ActivityIndicatorView()
}
}

View File

@ -45,14 +45,7 @@ struct ComposeAttachmentsList: View {
}
Button(action: self.addAttachment) {
if #available(iOS 14.0, *) {
Label("Add photo or video", systemImage: addButtonImageName)
} else {
HStack {
Image(systemName: addButtonImageName)
Text("Add photo or video")
}
}
}
.disabled(!canAddAttachment)
.foregroundColor(.blue)
@ -61,14 +54,7 @@ struct ComposeAttachmentsList: View {
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
Button(action: self.createDrawing) {
if #available(iOS 14.0, *) {
Label("Draw something", systemImage: "hand.draw")
} else {
HStack(alignment: .lastTextBaseline) {
Image(systemName: "hand.draw")
Text("Draw something")
}
}
}
.disabled(!canAddAttachment)
.foregroundColor(.blue)

View File

@ -44,19 +44,6 @@ struct ComposeAutocompleteView: View {
}
}
fileprivate extension View {
@ViewBuilder
func iOS13OnlyPadding() -> some View {
// on iOS 13, if the scroll view content's height changes after the view is added to the hierarchy,
// it doesn't appear on screen until interactive keyboard dismissal is started and then cancelled :S
if #available(iOS 14.0, *) {
self
} else {
self.frame(height: 46)
}
}
}
struct ComposeAutocompleteMentionsView: View {
@EnvironmentObject private var mastodonController: MastodonController
@EnvironmentObject private var uiState: ComposeUIState
@ -104,7 +91,6 @@ struct ComposeAutocompleteMentionsView: View {
Spacer()
}
.padding(.horizontal, 8)
.iOS13OnlyPadding()
}
.onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged)
.onDisappear {
@ -333,7 +319,6 @@ struct ComposeAutocompleteHashtagsView: View {
Spacer()
}
.padding(.horizontal, 8)
.iOS13OnlyPadding()
}
.onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged)
.onDisappear {

View File

@ -44,17 +44,11 @@ struct ComposeAvatarImageView: View {
private func loadImage() {
guard let url = url else { return }
request = ImageCache.avatars.get(url) { (data) in
if let data = data, let image = UIImage(data: data) {
request = ImageCache.avatars.get(url) { (_, image) in
DispatchQueue.main.async {
self.request = nil
self.avatarImage = image
}
} else {
DispatchQueue.main.async {
self.request = nil
}
}
}
}

View File

@ -58,11 +58,7 @@ class ComposeDrawingViewController: UIViewController {
canvasView.drawing = initialDrawing
}
canvasView.delegate = self
if #available(iOS 14.0, *) {
canvasView.drawingPolicy = .anyInput
} else {
canvasView.allowsFingerDrawing = true
}
canvasView.minimumZoomScale = 0.5
canvasView.maximumZoomScale = 2
canvasView.backgroundColor = .systemBackground

View File

@ -27,7 +27,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
private var cancellables = [AnyCancellable]()
private var keyboardHeight: CGFloat = 0
private var toolbarHeight: CGFloat = 44
private var mainToolbar: UIToolbar!
@ -115,13 +114,7 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
toolbar.translatesAutoresizingMaskIntoConstraints = false
toolbar.isAccessibilityElement = true
let visibilityAction: Selector?
if #available(iOS 14.0, *) {
visibilityAction = nil
} else {
visibilityAction = #selector(visibilityButtonPressed(_:))
}
let visibilityItem = UIBarButtonItem(image: UIImage(systemName: draft.visibility.imageName), style: .plain, target: self, action: visibilityAction)
let visibilityItem = UIBarButtonItem(image: UIImage(systemName: draft.visibility.imageName), style: .plain, target: nil, action: nil)
visibilityBarButtonItems.append(visibilityItem)
visibilityChanged(draft.visibility)
@ -135,7 +128,7 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
}
private func updateAdditionalSafeAreaInsets() {
additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: toolbarHeight + keyboardHeight, right: 0)
additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: toolbarHeight, right: 0)
}
@objc private func keyboardWillShow(_ notification: Foundation.Notification) {
@ -147,19 +140,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
accessoryView.alpha = 1
accessoryView.isHidden = false
// on iOS 14, SwiftUI safe area automatically includes the keyboard
if #available(iOS 14.0, *) {
} else {
let userInfo = notification.userInfo!
let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
// temporarily reset add'l safe area insets so we can access the default inset
additionalSafeAreaInsets = .zero
// there are a few extra points that come from somewhere, it seems to be four
// and without it, the autocomplete suggestions are cut off :S
keyboardHeight = frame.height - view.safeAreaInsets.bottom - accessoryView.frame.height + 4
updateAdditionalSafeAreaInsets()
}
}
@objc private func keyboardWillHide(_ notification: Foundation.Notification) {
@ -192,13 +172,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
} completion: { (finished) in
accessoryView.alpha = 1
}
// on iOS 14, SwiftUI safe area automatically includes the keyboard
if #available(iOS 14.0, *) {
} else {
keyboardHeight = 0
updateAdditionalSafeAreaInsets()
}
}
@objc private func keyboardDidHide(_ notification: Foundation.Notification) {
@ -214,7 +187,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
item.image = UIImage(systemName: newVisibility.imageName)
item.image!.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName)
item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName)
if #available(iOS 14.0, *) {
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
let state = visibility == newVisibility ? UIMenuElement.State.on : .off
return UIAction(title: visibility.displayName, image: UIImage(systemName: visibility.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { (_) in
@ -224,7 +196,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
item.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements)
}
}
}
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false }
@ -255,18 +226,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
draft.contentWarningEnabled = !draft.contentWarningEnabled
}
@objc func visibilityButtonPressed(_ sender: UIBarButtonItem) {
// if #available(iOS 14.0, *) {
// } else {
let alertController = UIAlertController(currentVisibility: draft.visibility) { (visibility) in
guard let visibility = visibility else { return }
self.draft.visibility = visibility
}
alertController.popoverPresentationController?.barButtonItem = sender
present(alertController, animated: true)
// }
}
@objc func draftsButtonPresed() {
let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!, exclude: draft)
draftsVC.delegate = self

View File

@ -44,15 +44,10 @@ struct ComposeView: View {
}
var body: some View {
// the pre-iOS 14 API does not result in the correct pointer interactions for nav bar buttons, see FB8595468
if #available(iOS 14.0, *) {
mostOfTheBody.toolbar {
ToolbarItem(placement: .cancellationAction) { cancelButton }
ToolbarItem(placement: .confirmationAction) { postButton }
}
} else {
mostOfTheBody.navigationBarItems(leading: cancelButton, trailing: postButton)
}
}
var mostOfTheBody: some View {
@ -82,8 +77,6 @@ struct ComposeView: View {
@ViewBuilder
var autocompleteSuggestions: some View {
// on iOS 13, the transition causes SwiftUI to hang on the main thread when the view appears, so it's disabled
if #available(iOS 14.0, *) {
VStack(spacing: 0) {
Spacer()
if let state = uiState.autocompleteState {
@ -92,14 +85,6 @@ struct ComposeView: View {
}
.transition(.move(edge: .bottom))
.animation(.default)
} else {
VStack(spacing: 0) {
Spacer()
if let state = uiState.autocompleteState {
ComposeAutocompleteView(autocompleteState: state)
}
}
}
}
func mainStack(outerMinY: CGFloat) -> some View {

View File

@ -45,15 +45,14 @@ class EmojiCollectionViewCell: UICollectionViewCell {
func updateUI(emoji: Emoji) {
currentEmojiShortcode = emoji.shortcode
imageRequest = ImageCache.emojis.get(emoji.url) { [weak self] (data) in
if let data = data, let image = UIImage(data: data) {
imageRequest = ImageCache.emojis.get(emoji.url) { [weak self] (_, image) in
guard let image = image else { return }
DispatchQueue.main.async { [weak self] in
guard let self = self, self.currentEmojiShortcode == emoji.shortcode else { return }
self.emojiImageView.image = image
}
}
}
}
override func prepareForReuse() {
super.prepareForReuse()

View File

@ -69,13 +69,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
uiState.autocompleteHandler = context.coordinator
let visibilityAction: Selector?
if #available(iOS 14.0, *) {
visibilityAction = nil
} else {
visibilityAction = #selector(ComposeHostingController.visibilityButtonPressed(_:))
}
let visibilityButton = UIBarButtonItem(image: UIImage(systemName: visibility.imageName), style: .plain, target: nil, action: visibilityAction)
let visibilityButton = UIBarButtonItem(image: UIImage(systemName: visibility.imageName), style: .plain, target: nil, action: nil)
updateVisibilityMenu(visibilityButton)
let toolbar = UIToolbar()
toolbar.translatesAutoresizingMaskIntoConstraints = false
@ -131,7 +125,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
}
private func updateVisibilityMenu(_ visibilityButton: UIBarButtonItem) {
if #available(iOS 14.0, *) {
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
let state = visibility == self.visibility ? UIMenuElement.State.on : .off
return UIAction(title: visibility.displayName, image: UIImage(systemName: visibility.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { (_) in
@ -140,7 +133,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
}
visibilityButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements)
}
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text

View File

@ -7,25 +7,31 @@
//
import UIKit
import SafariServices
import Pachyderm
import CoreData
class ConversationNode {
let status: StatusMO
var children: [ConversationNode]
init(status: StatusMO) {
self.status = status
self.children = []
}
}
class ConversationTableViewController: EnhancedTableViewController {
static let showPostsImage = UIImage(systemName: "eye.fill")!
static let hidePostsImage = UIImage(systemName: "eye.slash.fill")!
static let bottomSeparatorTag = 101
weak var mastodonController: MastodonController!
let mainStatusID: String
let mainStatusState: StatusState
var statuses: [(id: String, state: StatusState)] = [] {
didSet {
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
}
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
var showStatusesAutomatically = false
var visibilityBarButtonItem: UIBarButtonItem!
@ -46,7 +52,8 @@ class ConversationTableViewController: EnhancedTableViewController {
deinit {
guard let persistentContainer = mastodonController?.persistentContainer else { return }
for (id, _) in statuses {
let snapshot = dataSource.snapshot()
for case let .status(id: id, state: _) in snapshot.itemIdentifiers {
persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
@ -61,13 +68,76 @@ class ConversationTableViewController: EnhancedTableViewController {
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: nil), forCellReuseIdentifier: "statusCell")
tableView.register(UINib(nibName: "ConversationMainStatusTableViewCell", bundle: nil), forCellReuseIdentifier: "mainStatusCell")
tableView.register(UINib(nibName: "ExpandThreadTableViewCell", bundle: .main), forCellReuseIdentifier: "expandThreadCell")
tableView.prefetchDataSource = self
visibilityBarButtonItem = UIBarButtonItem(image: ConversationTableViewController.showPostsImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed))
tableView.backgroundColor = .secondarySystemBackground
// separators are disabled on the table view so we can re-add them ourselves
// so they're not inserted in between statuses in the ame sub-thread
tableView.separatorStyle = .none
dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
switch item {
case let .status(id: id, state: state):
let rowsInSection = self.dataSource.tableView(tableView, numberOfRowsInSection: indexPath.section)
let firstInSection = indexPath.row == 0
let lastInSection = indexPath.row == rowsInSection - 1
let identifier = id == self.mainStatusID ? "mainStatusCell" : "statusCell"
let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as! BaseStatusTableViewCell
if id == self.mainStatusID {
cell.selectionStyle = .none
}
cell.delegate = self
cell.showStatusAutomatically = self.showStatusesAutomatically
if let cell = cell as? TimelineStatusTableViewCell {
cell.showReplyIndicator = false
}
cell.updateUI(statusID: id, state: state)
cell.setShowThreadLinks(prev: !firstInSection, next: !lastInSection)
if lastInSection {
if cell.viewWithTag(ConversationTableViewController.bottomSeparatorTag) == nil {
let separator = UIView()
separator.tag = ConversationTableViewController.bottomSeparatorTag
separator.translatesAutoresizingMaskIntoConstraints = false
separator.backgroundColor = tableView.separatorColor
cell.addSubview(separator)
NSLayoutConstraint.activate([
separator.heightAnchor.constraint(equalToConstant: 0.5),
separator.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
separator.leftAnchor.constraint(equalTo: cell.leftAnchor, constant: cell.separatorInset.left),
separator.rightAnchor.constraint(equalTo: cell.rightAnchor),
])
}
} else {
cell.viewWithTag(ConversationTableViewController.bottomSeparatorTag)?.removeFromSuperview()
}
return cell
case let .expandThread(childThreads: childThreads):
let cell = tableView.dequeueReusableCell(withIdentifier: "expandThreadCell", for: indexPath) as! ExpandThreadTableViewCell
cell.updateUI(childThreads: childThreads)
return cell
}
})
let initialImage = showStatusesAutomatically ? ConversationTableViewController.hidePostsImage : ConversationTableViewController.showPostsImage
visibilityBarButtonItem = UIBarButtonItem(image: initialImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed))
navigationItem.rightBarButtonItem = visibilityBarButtonItem
statuses = [(mainStatusID, mainStatusState)]
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
snapshot.appendItems([mainStatusItem], toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: false)
guard let mainStatus = self.mastodonController.persistentContainer.status(for: self.mainStatusID) else {
fatalError("Missing cached status \(self.mainStatusID)")
@ -81,19 +151,39 @@ class ConversationTableViewController: EnhancedTableViewController {
let parents = self.getDirectParents(inReplyTo: mainStatusInReplyToID, from: context.ancestors)
let parentStatuses = context.ancestors.filter { parents.contains($0.id) }
self.mastodonController.persistentContainer.addAll(statuses: parentStatuses) {
self.mastodonController.persistentContainer.addAll(statuses: context.descendants) {
self.statuses = parents.map { ($0, .unknown) } + self.statuses + context.descendants.map { ($0.id, .unknown) }
let indexPath = IndexPath(row: parents.count, section: 0)
// todo: should this really be blindly adding all the descendants?
self.mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants) {
DispatchQueue.main.async {
var snapshot = self.dataSource.snapshot()
snapshot.insertItems(parents.map { .status(id: $0, state: .unknown) }, beforeItem: mainStatusItem)
// fetch all descendant status managed objects
let descendantIDs = context.descendants.map(\.id)
let request: NSFetchRequest<StatusMO> = StatusMO.fetchRequest()
request.predicate = NSPredicate(format: "id in %@", descendantIDs)
if let descendants = try? self.mastodonController.persistentContainer.viewContext.fetch(request) {
// convert array of descendant statuses into tree of sub-threads
let childThreads = self.getDescendantThreads(mainStatus: mainStatus, descendants: descendants)
// convert sub-threads into items for section and add to snapshot
self.addFlattenedChildThreadsToSnapshot(childThreads, mainStatus: mainStatus, snapshot: &snapshot)
}
self.dataSource.apply(snapshot, animatingDifferences: false) {
// ensure that the main status is on-screen after newly loaded statuses are added
// todo: should this not happen if the user has already started scrolling (e.g. because the main status is very long)?
if let indexPath = self.dataSource.indexPath(for: mainStatusItem) {
self.tableView.scrollToRow(at: indexPath, at: .middle, animated: false)
}
}
}
}
}
}
func getDirectParents(inReplyTo inReplyToID: String?, from statuses: [Status]) -> [String] {
private func getDirectParents(inReplyTo inReplyToID: String?, from statuses: [Status]) -> [String] {
var statuses = statuses
var parents = [String]()
@ -108,38 +198,99 @@ class ConversationTableViewController: EnhancedTableViewController {
return parents
}
// MARK: - Table view data source
private func getDescendantThreads(mainStatus: StatusMO, descendants: [StatusMO]) -> [ConversationNode] {
var descendants = descendants
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
func removeAllInReplyTo(id: String) -> [StatusMO] {
let statuses = descendants.filter { $0.inReplyToID == id }
descendants.removeAll { $0.inReplyToID == id }
return statuses
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return statuses.count
var nodes: [String: ConversationNode] = [
mainStatus.id: ConversationNode(status: mainStatus)
]
var idsToCheck = [mainStatusID]
while !idsToCheck.isEmpty {
let inReplyToID = idsToCheck.removeFirst()
let nodeForID = nodes[inReplyToID]!
let inReply = removeAllInReplyTo(id: inReplyToID)
for reply in inReply {
idsToCheck.append(reply.id)
let replyNode = ConversationNode(status: reply)
nodes[reply.id] = replyNode
nodeForID.children.append(replyNode)
}
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let (id, state) = statuses[indexPath.row]
return nodes[mainStatusID]!.children
}
if id == mainStatusID {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "mainStatusCell", for: indexPath) as? ConversationMainStatusTableViewCell else { fatalError() }
cell.selectionStyle = .none
cell.showStatusAutomatically = showStatusesAutomatically
cell.delegate = self
cell.updateUI(statusID: id, state: state)
return cell
private func addFlattenedChildThreadsToSnapshot(_ childThreads: [ConversationNode], mainStatus: StatusMO, snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
var childThreads = childThreads
// child threads by the same author as the main status come first
let pivotIndex = childThreads.partition(by: { $0.status.account.id != mainStatus.account.id })
// within each group, child threads are sorted chronologically
childThreads[0..<pivotIndex].sort(by: { $0.status.createdAt < $1.status.createdAt })
childThreads[pivotIndex...].sort(by: { $0.status.createdAt < $1.status.createdAt })
for node in childThreads {
snapshot.appendSections([.childThread(firstStatusID: node.status.id)])
snapshot.appendItems([.status(id: node.status.id, state: .unknown)])
var currentNode = node
while true {
let next: ConversationNode
if currentNode.children.count == 0 {
break
} else if currentNode.children.count == 1 {
next = currentNode.children[0]
} else {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as? TimelineStatusTableViewCell else { fatalError() }
cell.showStatusAutomatically = showStatusesAutomatically
cell.showReplyIndicator = false
cell.delegate = self
cell.updateUI(statusID: id, state: state)
return cell
let sameAuthorStatuses = currentNode.children.filter({ $0.status.account.id == node.status.account.id })
if sameAuthorStatuses.count == 1 {
next = sameAuthorStatuses[0]
} else {
snapshot.appendItems([.expandThread(childThreads: currentNode.children)])
break
}
}
currentNode = next
snapshot.appendItems([.status(id: next.status.id, state: .unknown)])
}
}
}
func item(for indexPath: IndexPath) -> (id: String, state: StatusState)? {
return self.dataSource.itemIdentifier(for: indexPath).flatMap { (item) in
switch item {
case let .status(id: id, state: state):
return (id: id, state: state)
default:
return nil
}
}
}
// MARK: - Table view delegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if case .expandThread = dataSource.itemIdentifier(for: indexPath),
case let .status(id: id, state: state) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) {
self.selected(status: id, state: state)
} else {
super.tableView(tableView, didSelectRowAt: indexPath)
}
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
@ -155,7 +306,8 @@ class ConversationTableViewController: EnhancedTableViewController {
@objc func toggleVisibilityButtonPressed() {
showStatusesAutomatically = !showStatusesAutomatically
for (_, state) in statuses where state.collapsible == true {
let snapshot = dataSource.snapshot()
for case let .status(id: _, state: state) in snapshot.itemIdentifiers where state.collapsible == true {
state.collapsed = !showStatusesAutomatically
}
@ -179,6 +331,48 @@ class ConversationTableViewController: EnhancedTableViewController {
}
extension ConversationTableViewController {
enum Section: Hashable {
case statuses
case childThread(firstStatusID: String)
}
enum Item: Hashable {
case status(id: String, state: StatusState)
case expandThread(childThreads: [ConversationNode])
static func == (lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case let (.status(id: a, state: _), .status(id: b, state: _)):
return a == b
case let (.expandThread(childThreads: a), .expandThread(childThreads: b)):
return zip(a, b).allSatisfy { $0.status.id == $1.status.id }
default:
return false
}
}
func hash(into hasher: inout Hasher) {
switch self {
case let .status(id: id, state: _):
hasher.combine("status")
hasher.combine(id)
case let .expandThread(childThreads: children):
hasher.combine("expandThread")
hasher.combine(children.map(\.status.id))
}
}
}
}
extension ConversationTableViewController: TuskerNavigationDelegate {
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController {
let vc = ConversationTableViewController(for: mainStatusID, state: state, mastodonController: mastodonController)
// transfer show statuses automatically state when showing new conversation
vc.showStatusesAutomatically = self.showStatusesAutomatically
return vc
}
}
extension ConversationTableViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController }
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
@ -188,24 +382,14 @@ extension ConversationTableViewController: StatusTableViewCellDelegate {
}
}
extension ConversationTableViewController: UITableViewDataSourcePrefetching {
extension ConversationTableViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { continue }
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments where attachment.kind == .image {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
}
}
let ids: [String] = indexPaths.compactMap { item(for: $0)?.id }
prefetchStatuses(with: ids)
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { continue }
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments where attachment.kind == .image {
ImageCache.attachments.cancelWithoutCallback(attachment.url)
}
}
let ids: [String] = indexPaths.compactMap { item(for: $0)?.id }
cancelPrefetchingStatuses(with: ids)
}
}

View File

@ -0,0 +1,112 @@
//
// ExpandThreadTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 1/30/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
class ExpandThreadTableViewCell: UITableViewCell {
@IBOutlet weak var avatarContainerView: UIView!
@IBOutlet weak var avatarContainerWidthConstraint: NSLayoutConstraint!
@IBOutlet weak var replyCountLabel: UILabel!
var avatarImageViews: [UIImageView] = []
private var avatarRequests: [ImageCache.Request] = []
override func awakeFromNib() {
super.awakeFromNib()
let prevThreadLinkView = UIView()
prevThreadLinkView.translatesAutoresizingMaskIntoConstraints = false
prevThreadLinkView.backgroundColor = tintColor.withAlphaComponent(0.5)
prevThreadLinkView.layer.cornerRadius = 2.5
prevThreadLinkView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
contentView.addSubview(prevThreadLinkView)
NSLayoutConstraint.activate([
prevThreadLinkView.widthAnchor.constraint(equalToConstant: 5),
prevThreadLinkView.centerXAnchor.constraint(equalTo: leadingAnchor, constant: 16 + 25),
prevThreadLinkView.topAnchor.constraint(equalTo: topAnchor),
prevThreadLinkView.bottomAnchor.constraint(equalTo: avatarContainerView.topAnchor, constant: -2),
])
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
func updateUI(childThreads: [ConversationNode]) {
let format = NSLocalizedString("expand threads count", comment: "expand conversation threads button label")
replyCountLabel.text = String.localizedStringWithFormat(format, childThreads.count)
let accounts = childThreads.map(\.status.account).uniques().prefix(3)
avatarImageViews.forEach { $0.removeFromSuperview() }
avatarImageViews = []
avatarRequests = []
let avatarImageSize: CGFloat = 44 - 12
if accounts.count == 1 {
avatarContainerWidthConstraint.constant = avatarImageSize
} else {
avatarContainerWidthConstraint.constant = CGFloat(accounts.count) * avatarImageSize * 3 / 4
}
for (index, account) in accounts.enumerated() {
let accountImageView = UIImageView()
accountImageView.translatesAutoresizingMaskIntoConstraints = false
accountImageView.contentMode = .scaleAspectFit
accountImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * avatarImageSize
accountImageView.layer.masksToBounds = true
accountImageView.layer.borderWidth = 1
accountImageView.layer.borderColor = UIColor.secondarySystemBackground.cgColor
// need a solid background color so semi-transparent avatars don't look bad
accountImageView.backgroundColor = .secondarySystemBackground
avatarContainerView.addSubview(accountImageView)
avatarImageViews.append(accountImageView)
accountImageView.layer.zPosition = CGFloat(-index)
let xConstraint: NSLayoutConstraint
if index == 0 {
xConstraint = accountImageView.leadingAnchor.constraint(equalTo: avatarContainerView.leadingAnchor)
} else if index == accounts.count - 1 {
xConstraint = accountImageView.trailingAnchor.constraint(equalTo: avatarContainerView.trailingAnchor)
} else {
xConstraint = accountImageView.centerXAnchor.constraint(equalTo: avatarContainerView.centerXAnchor)
}
NSLayoutConstraint.activate([
accountImageView.widthAnchor.constraint(equalToConstant: avatarImageSize),
accountImageView.heightAnchor.constraint(equalToConstant: avatarImageSize),
accountImageView.centerYAnchor.constraint(equalTo: avatarContainerView.centerYAnchor),
xConstraint
])
let req = ImageCache.avatars.get(account.avatar) { [weak accountImageView] (_, image) in
DispatchQueue.main.async {
accountImageView?.image = image
}
}
if let req = req {
avatarRequests.append(req)
}
}
}
@objc private func preferencesChanged() {
avatarImageViews.forEach {
$0.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: $0)
}
}
override func prepareForReuse() {
super.prepareForReuse()
avatarRequests.forEach { $0.cancel() }
}
}

View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" 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="17703"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" id="KGk-i7-Jjw" customClass="ExpandThreadTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<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="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="IXi-sc-YIw">
<rect key="frame" x="16" y="0.0" width="173" height="44"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="eFB-F1-d3A">
<rect key="frame" x="0.0" y="6" width="100" height="32"/>
<constraints>
<constraint firstAttribute="height" constant="32" id="g9U-u7-718"/>
<constraint firstAttribute="width" constant="100" id="tiI-Rj-gjh"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="2 replies" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Dcm-ll-GeE">
<rect key="frame" x="108" y="12" width="65" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" systemColor="systemBlueColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstAttribute="height" constant="44" id="jk2-uV-FdO"/>
</constraints>
</stackView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Kkt-hM-ScW">
<rect key="frame" x="16" y="43.5" width="304" height="0.5"/>
<color key="backgroundColor" systemColor="separatorColor"/>
<constraints>
<constraint firstAttribute="height" constant="0.5" id="Fkq-bT-IYv"/>
</constraints>
</view>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="Kkt-hM-ScW" secondAttribute="bottom" id="AvY-H1-0YN"/>
<constraint firstItem="Kkt-hM-ScW" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="16" id="E5g-hz-SLI"/>
<constraint firstItem="IXi-sc-YIw" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" id="SRF-Zx-Y0R"/>
<constraint firstAttribute="trailing" secondItem="Kkt-hM-ScW" secondAttribute="trailing" id="YML-R1-ezq"/>
<constraint firstItem="IXi-sc-YIw" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" id="iD5-Av-ORS"/>
<constraint firstAttribute="bottom" secondItem="IXi-sc-YIw" secondAttribute="bottom" id="kpD-6Q-qKi"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
<connections>
<outlet property="avatarContainerView" destination="eFB-F1-d3A" id="xGo-40-nn7"/>
<outlet property="avatarContainerWidthConstraint" destination="tiI-Rj-gjh" id="34n-ev-EKi"/>
<outlet property="replyCountLabel" destination="Dcm-ll-GeE" id="E4m-xk-DiQ"/>
</connections>
<point key="canvasLocation" x="132" y="132"/>
</tableViewCell>
</objects>
<resources>
<systemColor name="secondarySystemBackgroundColor">
<color red="0.94901960784313721" green="0.94901960784313721" blue="0.96862745098039216" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="separatorColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.28999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="systemBlueColor">
<color red="0.0" green="0.47843137254901963" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@ -9,12 +9,19 @@
import UIKit
import Pachyderm
class AddSavedHashtagViewController: SearchResultsViewController {
class AddSavedHashtagViewController: EnhancedTableViewController {
weak var mastodonController: MastodonController!
var resultsController: SearchResultsViewController!
var searchController: UISearchController!
var dataSource: UITableViewDiffableDataSource<Section, Item>!
init(mastodonController: MastodonController) {
super.init(mastodonController: mastodonController, resultTypes: [.hashtags])
self.mastodonController = mastodonController
super.init(style: .grouped)
}
required init?(coder: NSCoder) {
@ -24,14 +31,32 @@ class AddSavedHashtagViewController: SearchResultsViewController {
override func viewDidLoad() {
super.viewDidLoad()
delegate = self
title = NSLocalizedString("Search", comment: "search screen title")
searchController = UISearchController(searchResultsController: nil)
searchController.obscuresBackgroundDuringPresentation = false
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
switch item {
case let .tag(hashtag):
let cell = tableView.dequeueReusableCell(withIdentifier: "trendingTagCell", for: indexPath) as! TrendingHashtagTableViewCell
cell.updateUI(hashtag: hashtag)
return cell
}
})
resultsController = HashtagSearchResultsViewController(mastodonController: mastodonController)
resultsController.delegate = self
resultsController.exploreNavigationController = self.navigationController!
searchController = UISearchController(searchResultsController: resultsController)
searchController.obscuresBackgroundDuringPresentation = true
searchController.hidesNavigationBarDuringPresentation = false
searchController.searchResultsUpdater = resultsController
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.placeholder = NSLocalizedString("Search for hashtags to save", comment: "add saved hashtag search field placeholder")
searchController.searchBar.delegate = self
searchController.searchBar.delegate = resultsController
searchController.searchBar.showsCancelButton = false
definesPresentationContext = true
@ -41,11 +66,38 @@ class AddSavedHashtagViewController: SearchResultsViewController {
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed))
}
override func performSearch(query: String?) {
if let query = query, !query.starts(with: "#") {
super.performSearch(query: "#\(query)")
} else {
super.performSearch(query: query)
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
let request = Client.getTrends(limit: 10)
mastodonController.run(request) { (response) in
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
guard case let .success(hashtags, _) = response,
hashtags.count > 0 else {
self.dataSource.apply(snapshot)
return
}
snapshot.appendSections([.trendingTags])
snapshot.appendItems(hashtags.map { .tag($0) })
self.dataSource.apply(snapshot, animatingDifferences: false)
}
}
private func selectHashtag(_ hashtag: Hashtag) {
SavedDataManager.shared.add(hashtag: hashtag, for: mastodonController.accountInfo!)
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)
}
}
@ -57,9 +109,24 @@ class AddSavedHashtagViewController: SearchResultsViewController {
}
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")
}
}
}
extension AddSavedHashtagViewController: SearchResultsViewControllerDelegate {
func selectedSearchResult(hashtag: Hashtag) {
SavedDataManager.shared.add(hashtag: hashtag, for: mastodonController.accountInfo!)
dismiss(animated: true)
selectHashtag(hashtag)
}
}

View File

@ -10,23 +10,22 @@ import UIKit
import Combine
import Pachyderm
class ExploreViewController: EnhancedTableViewController {
class ExploreViewController: UIViewController, UICollectionViewDelegate {
weak var mastodonController: MastodonController!
var dataSource: DataSource!
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var resultsController: SearchResultsViewController!
var searchController: UISearchController!
private(set) var resultsController: SearchResultsViewController!
private(set) var searchController: UISearchController!
var searchControllerStatusOnAppearance: Bool? = nil
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
super.init(style: .insetGrouped)
dragEnabled = true
super.init(nibName: nil, bundle: nil)
title = NSLocalizedString("Explore", comment: "explore tab title")
tabBarItem.image = UIImage(systemName: "magnifyingglass")
@ -39,63 +38,23 @@ class ExploreViewController: EnhancedTableViewController {
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UINib(nibName: "BasicTableViewCell", bundle: .main), forCellReuseIdentifier: "basicCell")
var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
configuration.trailingSwipeActionsConfigurationProvider = self.trailingSwipeActionsForCell(at:)
configuration.headerMode = .supplementary
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.delegate = self
collectionView.dragDelegate = self
view.addSubview(collectionView)
dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
let cell = tableView.dequeueReusableCell(withIdentifier: "basicCell", for: indexPath)
dataSource = createDataSource()
applyInitialSnapshot()
switch item {
case .bookmarks:
cell.imageView!.image = UIImage(systemName: "bookmark.fill")
cell.textLabel!.text = NSLocalizedString("Bookmarks", comment: "bookmarks nav item title")
cell.accessoryType = .disclosureIndicator
case let .list(list):
cell.imageView!.image = UIImage(systemName: "list.bullet")
cell.textLabel!.text = list.title
cell.accessoryType = .disclosureIndicator
case .addList:
cell.imageView!.image = UIImage(systemName: "plus")
cell.textLabel!.text = NSLocalizedString("New List...", comment: "new list nav item title")
cell.accessoryType = .none
case let .savedHashtag(hashtag):
cell.imageView!.image = UIImage(systemName: "number")
cell.textLabel!.text = hashtag.name
cell.accessoryType = .disclosureIndicator
case .addSavedHashtag:
cell.imageView!.image = UIImage(systemName: "plus")
cell.textLabel!.text = NSLocalizedString("Save Hashtag...", comment: "save hashtag nav item title")
cell.accessoryType = .none
case let .savedInstance(url):
cell.imageView!.image = UIImage(systemName: "globe")
cell.textLabel!.text = url.host!
cell.accessoryType = .disclosureIndicator
case .findInstance:
cell.imageView!.image = UIImage(systemName: "magnifyingglass")
cell.textLabel!.text = NSLocalizedString("Find An Instance...", comment: "find instance nav item title")
cell.accessoryType = .none
if mastodonController.instance == nil {
mastodonController.getOwnInstance(completion: self.ownInstanceLoaded(_:))
}
return cell
})
dataSource.exploreController = self
let account = mastodonController.accountInfo!
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.bookmarks, .lists, .savedHashtags, .savedInstances])
snapshot.appendItems([.bookmarks], toSection: .bookmarks)
snapshot.appendItems([.addList], toSection: .lists)
snapshot.appendItems(SavedDataManager.shared.sortedHashtags(for: account).map { .savedHashtag($0) } + [.addSavedHashtag], toSection: .savedHashtags)
snapshot.appendItems(SavedDataManager.shared.savedInstances(for: account).map { .savedInstance($0) } + [.findInstance], toSection: .savedInstances)
// the initial, static items should not be displayed with an animation
dataSource.apply(snapshot, animatingDifferences: false)
resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController!
searchController = UISearchController(searchResultsController: resultsController)
@ -109,8 +68,20 @@ class ExploreViewController: EnhancedTableViewController {
NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil)
}
reloadLists()
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Can't use UICollectionViewController's builtin version of this because it requires
// the collection view layout be passed into the constructor. Swipe actions for list collection views
// are created by passing a closure to the layout's configuration. This closure needs to capture
// `self`, so it can't be passed into the super constructor.
if let indexPaths = collectionView.indexPathsForSelectedItems {
for indexPath in indexPaths {
collectionView.deselectItem(at: indexPath, animated: true)
}
}
}
override func viewDidAppear(_ animated: Bool) {
@ -125,16 +96,82 @@ class ExploreViewController: EnhancedTableViewController {
}
}
func reloadLists() {
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { (headerView, collectionView, indexPath) in
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
var config = headerView.defaultContentConfiguration()
config.text = section.label
headerView.contentConfiguration = config
}
let listCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (cell, indexPath, item) in
var config = cell.defaultContentConfiguration()
config.text = item.label
config.image = item.image
cell.contentConfiguration = config
switch item {
case .addList, .addSavedHashtag, .findInstance:
cell.accessories = []
default:
cell.accessories = [.disclosureIndicator()]
}
}
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { (collectionView, indexPath, item) in
return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: item)
}
dataSource.supplementaryViewProvider = { (collectionView, elementKind, indexPath) in
if elementKind == UICollectionView.elementKindSectionHeader {
return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderCell, for: indexPath)
} else {
return nil
}
}
return dataSource
}
private func applyInitialSnapshot() {
let account = mastodonController.accountInfo!
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(Section.allCases.filter { $0 != .discover })
snapshot.appendItems([.bookmarks], toSection: .bookmarks)
if case .mastodon = mastodonController.instance?.instanceType {
snapshot.insertSections([.discover], afterSection: .bookmarks)
snapshot.appendItems([.trendingTags, .profileDirectory], toSection: .discover)
}
snapshot.appendItems([.addList], toSection: .lists)
snapshot.appendItems(SavedDataManager.shared.sortedHashtags(for: account).map { .savedHashtag($0) }, toSection: .savedHashtags)
snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
snapshot.appendItems(SavedDataManager.shared.savedInstances(for: account).map { .savedInstance($0) }, toSection: .savedInstances)
snapshot.appendItems([.findInstance], toSection: .savedInstances)
dataSource.apply(snapshot, animatingDifferences: false)
reloadLists()
}
private func ownInstanceLoaded(_ instance: Instance) {
var snapshot = self.dataSource.snapshot()
if case .mastodon = instance.instanceType {
snapshot.insertSections([.discover], afterSection: .bookmarks)
snapshot.appendItems([.trendingTags, .profileDirectory], toSection: .discover)
}
self.dataSource.apply(snapshot)
}
private func reloadLists() {
let request = Client.getLists()
mastodonController.run(request) { (response) in
guard case let .success(lists, _) = response else {
fatalError()
return
}
var snapshot = self.dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .lists))
snapshot.appendItems(lists.map { .list($0) } + [.addList], toSection: .lists)
snapshot.appendItems(lists.map { .list($0) }, toSection: .lists)
snapshot.appendItems([.addList], toSection: .lists)
DispatchQueue.main.async {
self.dataSource.apply(snapshot)
@ -142,26 +179,31 @@ class ExploreViewController: EnhancedTableViewController {
}
}
@objc func savedHashtagsChanged() {
@objc private func savedHashtagsChanged() {
let account = mastodonController.accountInfo!
var snapshot = dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags))
snapshot.appendItems(SavedDataManager.shared.sortedHashtags(for: account).map { .savedHashtag($0) } + [.addSavedHashtag], toSection: .savedHashtags)
snapshot.appendItems(SavedDataManager.shared.sortedHashtags(for: account).map { .savedHashtag($0) }, toSection: .savedHashtags)
snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
dataSource.apply(snapshot)
}
@objc func savedInstancesChanged() {
@objc private func savedInstancesChanged() {
let account = mastodonController.accountInfo!
var snapshot = dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedInstances))
snapshot.appendItems(SavedDataManager.shared.savedInstances(for: account).map { .savedInstance($0) } + [.findInstance], toSection: .savedInstances)
snapshot.appendItems(SavedDataManager.shared.savedInstances(for: account).map { .savedInstance($0) }, toSection: .savedInstances)
snapshot.appendItems([.findInstance], toSection: .savedInstances)
dataSource.apply(snapshot)
}
func deleteList(_ list: List) {
let title = String(format: NSLocalizedString("Are you sure want to delete the '%@' list?", comment: "delete list alert title"), list.title)
private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) {
let titleFormat = NSLocalizedString("Are you sure you want to delete the '%@' list?", comment: "delete list alert title")
let title = String(format: titleFormat, list.title)
let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "delete list alert cancel button"), style: .cancel, handler: nil))
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "delete list alert cancel button"), style: .cancel, handler: { (_) in
completion(false)
}))
alert.addAction(UIAlertAction(title: NSLocalizedString("Delete List", comment: "delete list alert confirm button"), style: .destructive, handler: { (_) in
let request = List.delete(list)
@ -174,6 +216,7 @@ class ExploreViewController: EnhancedTableViewController {
snapshot.deleteItems([.list(list)])
DispatchQueue.main.async {
self.dataSource.apply(snapshot)
completion(true)
}
}
}))
@ -190,9 +233,38 @@ class ExploreViewController: EnhancedTableViewController {
SavedDataManager.shared.remove(instance: instanceURL, for: account)
}
// MARK: - Table view delegate
private func trailingSwipeActionsForCell(at indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let handler: UIContextualAction.Handler
switch dataSource.itemIdentifier(for: indexPath) {
case let .list(list):
handler = { (_, _, completion) in
self.deleteList(list, completion: completion)
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
case let .savedHashtag(hashtag):
handler = { (_, _, completion) in
self.removeSavedHashtag(hashtag)
completion(true)
}
case let .savedInstance(url):
handler = { (_, _, completion) in
self.removeSavedInstance(url)
completion(true)
}
default:
return nil
}
return UISwipeActionsConfiguration(actions: [
UIContextualAction(style: .destructive, title: NSLocalizedString("Delete", comment: "delete swipe action title"), handler: handler)
])
}
// MARK: - Collection View Delegate
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
switch dataSource.itemIdentifier(for: indexPath) {
case nil:
return
@ -200,11 +272,17 @@ class ExploreViewController: EnhancedTableViewController {
case .bookmarks:
show(BookmarksTableViewController(mastodonController: mastodonController), sender: nil)
case .trendingTags:
show(TrendingHashtagsViewController(mastodonController: mastodonController), sender: nil)
case .profileDirectory:
show(ProfileDirectoryViewController(mastodonController: mastodonController), sender: nil)
case let .list(list):
show(ListTimelineViewController(for: list, mastodonController: mastodonController), sender: nil)
case .addList:
tableView.selectRow(at: nil, animated: true, scrollPosition: .none)
collectionView.deselectItem(at: indexPath, animated: true)
let alert = UIAlertController(title: NSLocalizedString("New List", comment: "new list alert title"), message: NSLocalizedString("Choose a title for your new list", comment: "new list alert message"), preferredStyle: .alert)
alert.addTextField(configurationHandler: nil)
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "new list alert cancel button"), style: .cancel, handler: nil))
@ -232,7 +310,7 @@ class ExploreViewController: EnhancedTableViewController {
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
case .addSavedHashtag:
tableView.selectRow(at: nil, animated: true, scrollPosition: .none)
collectionView.deselectItem(at: indexPath, animated: true)
let navController = UINavigationController(rootViewController: AddSavedHashtagViewController(mastodonController: mastodonController))
present(navController, animated: true)
@ -240,7 +318,7 @@ class ExploreViewController: EnhancedTableViewController {
show(InstanceTimelineViewController(for: url, parentMastodonController: mastodonController), sender: nil)
case .findInstance:
tableView.selectRow(at: nil, animated: true, scrollPosition: .none)
collectionView.deselectItem(at: indexPath, animated: true)
let findController = FindInstanceViewController(parentMastodonController: mastodonController)
findController.instanceTimelineDelegate = self
let navController = UINavigationController(rootViewController: findController)
@ -248,48 +326,36 @@ class ExploreViewController: EnhancedTableViewController {
}
}
override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
return .delete
}
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
switch dataSource.itemIdentifier(for: indexPath) {
case .bookmarks:
return UIContextMenuConfiguration(identifier: nil, previewProvider: {
return BookmarksTableViewController(mastodonController: self.mastodonController)
}, actionProvider: nil)
case let .list(list):
return UIContextMenuConfiguration(identifier: nil, previewProvider: {
return ListTimelineViewController(for: list, mastodonController: self.mastodonController)
}, actionProvider: nil)
case let .savedHashtag(hashtag):
return UIContextMenuConfiguration(identifier: nil, previewProvider: {
return HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
}, actionProvider: nil)
case let .savedInstance(url):
return UIContextMenuConfiguration(identifier: nil, previewProvider: {
return InstanceTimelineViewController(for: url, parentMastodonController: self.mastodonController)
}, actionProvider: nil)
default:
return nil
}
}
}
extension ExploreViewController {
enum Section: CaseIterable {
case bookmarks
case discover
case lists
case savedHashtags
case savedInstances
var label: String? {
switch self {
case .bookmarks:
return nil
case .discover:
return NSLocalizedString("Discover", comment: "discover section title")
case .lists:
return NSLocalizedString("Lists", comment: "explore lists section title")
case .savedHashtags:
return NSLocalizedString("Saved Hashtags", comment: "explore saved hashtags section title")
case .savedInstances:
return NSLocalizedString("Instance Timelines", comment: "explore instance timelines section title")
}
}
}
enum Item: Hashable {
case bookmarks
case trendingTags
case profileDirectory
case list(List)
case addList
case savedHashtag(Hashtag)
@ -297,10 +363,60 @@ extension ExploreViewController {
case savedInstance(URL)
case findInstance
static func == (lhs: ExploreViewController.Item, rhs: ExploreViewController.Item) -> Bool {
var label: String {
switch self {
case .bookmarks:
return NSLocalizedString("Bookmarks", comment: "bookmarks nav item title")
case .trendingTags:
return NSLocalizedString("Trending Hashtags", comment: "trending hashtags nav item title")
case .profileDirectory:
return NSLocalizedString("Profile Directory", comment: "profile directory nav item title")
case let .list(list):
return list.title
case .addList:
return NSLocalizedString("New List...", comment: "new list nav item title")
case let .savedHashtag(hashtag):
return hashtag.name
case .addSavedHashtag:
return NSLocalizedString("Save Hashtag...", comment: "save hashtag nav item title")
case let .savedInstance(url):
return url.host!
case .findInstance:
return NSLocalizedString("Find An Instance...", comment: "find instance nav item title")
}
}
var image: UIImage {
let name: String
switch self {
case .bookmarks:
name = "bookmark.fill"
case .trendingTags:
name = "arrow.up.arrow.down"
case .profileDirectory:
name = "person.2.fill"
case .list(_):
name = "list.bullet"
case .addList, .addSavedHashtag:
name = "plus"
case .savedHashtag(_):
name = "number"
case .savedInstance(_):
name = "globe"
case .findInstance:
name = "magnifyingglass"
}
return UIImage(systemName: name)!
}
static func == (lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case (.bookmarks, .bookmarks):
return true
case (.trendingTags, .trendingTags):
return true
case (.profileDirectory, .profileDirectory):
return true
case let (.list(a), .list(b)):
return a.id == b.id
case (.addList, .addList):
@ -317,10 +433,15 @@ extension ExploreViewController {
return false
}
}
func hash(into hasher: inout Hasher) {
switch self {
case .bookmarks:
hasher.combine("bookmarks")
case .trendingTags:
hasher.combine("trendingTags")
case .profileDirectory:
hasher.combine("profileDirectory")
case let .list(list):
hasher.combine("list")
hasher.combine(list.id)
@ -339,57 +460,6 @@ extension ExploreViewController {
}
}
}
class DataSource: UITableViewDiffableDataSource<Section, Item> {
weak var exploreController: ExploreViewController?
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
switch section {
case 1:
return NSLocalizedString("Lists", comment: "explore lists section title")
case 2:
return NSLocalizedString("Saved Hashtags", comment: "explore saved hashtags section title")
case 3:
return NSLocalizedString("Instance Timelines", comment: "explore instance timelines section title")
default:
return nil
}
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
switch itemIdentifier(for: indexPath) {
case .list(_):
return true
case .savedHashtag(_):
return true
case .savedInstance(_):
return true
default:
return false
}
}
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
guard editingStyle == .delete,
let exploreController = exploreController else {
return
}
switch itemIdentifier(for: indexPath) {
case let .list(list):
exploreController.deleteList(list)
case let .savedHashtag(hashtag):
exploreController.removeSavedHashtag(hashtag)
case let .savedInstance(url):
exploreController.removeSavedInstance(url)
default:
return
}
}
}
}
extension ExploreViewController: InstanceTimelineViewControllerDelegate {
@ -404,12 +474,13 @@ extension ExploreViewController: InstanceTimelineViewControllerDelegate {
}
}
extension ExploreViewController {
override func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
extension ExploreViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let item = dataSource.itemIdentifier(for: indexPath),
let accountID = mastodonController.accountInfo?.id else {
return []
}
let provider: NSItemProvider
switch item {
case .bookmarks:
@ -425,11 +496,7 @@ extension ExploreViewController {
case let .savedInstance(url):
provider = NSItemProvider(object: url as NSURL)
// todo: should dragging public timelines into new windows be supported?
case .addList:
return []
case .addSavedHashtag:
return []
case .findInstance:
case .trendingTags, .profileDirectory, .addList, .addSavedHashtag, .findInstance:
return []
}
return [UIDragItem(itemProvider: provider)]

View File

@ -0,0 +1,87 @@
//
// FeaturedProfileCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 2/6/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class FeaturedProfileCollectionViewCell: UICollectionViewCell {
@IBOutlet weak var headerImageView: UIImageView!
@IBOutlet weak var avatarContainerView: UIView!
@IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var displayNameLabel: EmojiLabel!
@IBOutlet weak var noteTextView: StatusContentTextView!
var account: Account?
private var avatarRequest: ImageCache.Request?
private var headerRequest: ImageCache.Request?
override func awakeFromNib() {
super.awakeFromNib()
avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView)
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
noteTextView.defaultFont = .systemFont(ofSize: 16)
noteTextView.textContainer.lineBreakMode = .byTruncatingTail
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
func updateUI(account: Account) {
self.account = account
displayNameLabel.updateForAccountDisplayName(account: account)
noteTextView.setTextFromHtml(account.note)
noteTextView.setEmojis(account.emojis)
avatarImageView.image = nil
avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (_, image) in
defer {
self?.avatarRequest = nil
}
guard let self = self,
let image = image,
self.account?.id == account.id else {
return
}
DispatchQueue.main.async {
self.avatarImageView.image = image
}
}
headerImageView.image = nil
if let header = account.header {
headerRequest = ImageCache.headers.get(header) { [weak self] (_, image) in
defer {
self?.headerRequest = nil
}
guard let self = self,
let image = image,
self.account?.id == account.id else {
return
}
DispatchQueue.main.async {
self.headerImageView.image = image
}
}
}
}
@objc private func preferencesChanged() {
avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView)
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
if let account = account {
displayNameLabel.updateForAccountDisplayName(account: account)
}
}
}

View File

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18121" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18091"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" id="gTV-IL-0wX" customClass="FeaturedProfileCollectionViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="400" height="200"/>
<autoresizingMask key="autoresizingMask"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center">
<rect key="frame" x="0.0" y="0.0" width="400" height="200"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="bo4-Sd-caI">
<rect key="frame" x="0.0" y="0.0" width="400" height="66"/>
<color key="backgroundColor" systemColor="systemGray5Color"/>
<constraints>
<constraint firstAttribute="height" constant="66" id="9Aa-Up-chJ"/>
</constraints>
</imageView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="RQe-uE-TEv">
<rect key="frame" x="8" y="34" width="64" height="64"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="4wd-wq-Sh2">
<rect key="frame" x="2" y="2" width="60" height="60"/>
<constraints>
<constraint firstAttribute="width" constant="60" id="Xyl-Ry-J3r"/>
<constraint firstAttribute="width" secondItem="4wd-wq-Sh2" secondAttribute="height" multiplier="1:1" id="YEc-fT-FRB"/>
</constraints>
</imageView>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="width" secondItem="RQe-uE-TEv" secondAttribute="height" multiplier="1:1" id="4vR-IF-yS8"/>
<constraint firstAttribute="width" secondItem="4wd-wq-Sh2" secondAttribute="width" constant="4" id="52Q-zq-k28"/>
<constraint firstItem="4wd-wq-Sh2" firstAttribute="centerY" secondItem="RQe-uE-TEv" secondAttribute="centerY" id="Ped-H7-QtP"/>
<constraint firstItem="4wd-wq-Sh2" firstAttribute="centerX" secondItem="RQe-uE-TEv" secondAttribute="centerX" id="bRk-uJ-JGg"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" minimumFontSize="10" adjustsLetterSpacingToFitWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="voW-Is-1b2" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="76" y="72" width="316" height="24"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="20"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="749" scrollEnabled="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bvj-F0-ggC" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="8" y="102" width="384" height="94"/>
<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"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
</subviews>
</view>
<viewLayoutGuide key="safeArea" id="ZTg-uK-7eu"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstItem="bvj-F0-ggC" firstAttribute="top" secondItem="RQe-uE-TEv" secondAttribute="bottom" constant="4" id="8Nc-FF-kRX"/>
<constraint firstItem="bo4-Sd-caI" firstAttribute="top" secondItem="gTV-IL-0wX" secondAttribute="top" id="CJ1-Be-L45"/>
<constraint firstAttribute="bottom" secondItem="bvj-F0-ggC" secondAttribute="bottom" constant="4" id="Hza-qE-Agk"/>
<constraint firstItem="voW-Is-1b2" firstAttribute="bottom" secondItem="4wd-wq-Sh2" secondAttribute="bottom" id="N0l-fE-AAX"/>
<constraint firstItem="RQe-uE-TEv" firstAttribute="centerY" secondItem="bo4-Sd-caI" secondAttribute="bottom" id="Ngh-DO-Q0X"/>
<constraint firstItem="bvj-F0-ggC" firstAttribute="leading" secondItem="gTV-IL-0wX" secondAttribute="leading" constant="8" id="Rjq-1i-PV2"/>
<constraint firstItem="voW-Is-1b2" firstAttribute="leading" secondItem="RQe-uE-TEv" secondAttribute="trailing" constant="4" id="WUb-3i-BFe"/>
<constraint firstAttribute="trailing" secondItem="bvj-F0-ggC" secondAttribute="trailing" constant="8" id="ZrT-Wa-pbY"/>
<constraint firstItem="voW-Is-1b2" firstAttribute="top" relation="greaterThanOrEqual" secondItem="bo4-Sd-caI" secondAttribute="bottom" id="g4l-yF-2wH"/>
<constraint firstAttribute="trailing" secondItem="bo4-Sd-caI" secondAttribute="trailing" id="geb-Qa-zZp"/>
<constraint firstAttribute="trailing" secondItem="voW-Is-1b2" secondAttribute="trailing" constant="8" id="l91-F6-kAL"/>
<constraint firstItem="bo4-Sd-caI" firstAttribute="leading" secondItem="gTV-IL-0wX" secondAttribute="leading" id="tUr-Oy-nXN"/>
<constraint firstItem="RQe-uE-TEv" firstAttribute="leading" secondItem="gTV-IL-0wX" secondAttribute="leading" constant="8" id="uZI-LM-bZW"/>
</constraints>
<connections>
<outlet property="avatarContainerView" destination="RQe-uE-TEv" id="tBI-fT-26P"/>
<outlet property="avatarImageView" destination="4wd-wq-Sh2" id="rba-cv-8fb"/>
<outlet property="displayNameLabel" destination="voW-Is-1b2" id="XVS-4d-PKx"/>
<outlet property="headerImageView" destination="bo4-Sd-caI" id="YkL-Wi-BXb"/>
<outlet property="noteTextView" destination="bvj-F0-ggC" id="Bbm-ai-bu1"/>
</connections>
<point key="canvasLocation" x="535" y="428"/>
</collectionViewCell>
</objects>
<resources>
<systemColor name="labelColor">
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<systemColor name="systemGray5Color">
<color red="0.89803921568627454" green="0.89803921568627454" blue="0.91764705882352937" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@ -0,0 +1,29 @@
//
// HashtagSearchResultsViewController.swift
// Tusker
//
// Created by Shadowfacts on 1/24/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
class HashtagSearchResultsViewController: SearchResultsViewController {
init(mastodonController: MastodonController) {
super.init(mastodonController: mastodonController, resultTypes: [.hashtags])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func performSearch(query: String?) {
if let query = query, !query.starts(with: "#") {
super.performSearch(query: "#\(query)")
} else {
super.performSearch(query: query)
}
}
}

View File

@ -0,0 +1,132 @@
//
// 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)
let sortLabel = UILabel()
sortLabel.translatesAutoresizingMaskIntoConstraints = false
sortLabel.text = NSLocalizedString("Sort By", comment: "profile directory sort label")
sortLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
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: scope.selectedSegmentIndex)!
let order = sort.selectedSegmentIndex == 0 ? DirectoryOrder.active : .new
onFilterChanged?(scope, order)
}
}
extension ProfileDirectoryFilterView {
enum Scope: Int, Equatable {
case instance, everywhere
}
}

View File

@ -0,0 +1,191 @@
//
// ProfileDirectoryViewController.swift
// Tusker
//
// Created by Shadowfacts on 2/6/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class ProfileDirectoryViewController: UIViewController {
weak var mastodonController: MastodonController!
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
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
)
]
let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) in
let itemHeight = NSCollectionLayoutDimension.absolute(200)
let itemWidth: NSCollectionLayoutDimension
if case .compact = layoutEnvironment.traitCollection.horizontalSizeClass {
itemWidth = .fractionalWidth(1)
} else {
itemWidth = .absolute((layoutEnvironment.container.contentSize.width - 12) / 2)
}
let itemSize = NSCollectionLayoutSize(widthDimension: itemWidth, heightDimension: itemHeight)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let itemB = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: itemHeight)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, itemB])
group.interItemSpacing = .flexible(4)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 4
if case .compact = layoutEnvironment.traitCollection.horizontalSizeClass {
section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)
} else {
section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)
}
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)
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { (collectionView, indexPath, item) in
guard case let .account(account) = item else { fatalError() }
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "featuredProfileCell", for: indexPath) as! FeaturedProfileCollectionViewCell
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) {
let request = Client.getFeaturedProfiles(local: local, order: order)
mastodonController.run(request) { (response) in
guard case let .success(accounts, _) = response else {
return
}
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.featuredProfiles])
snapshot.appendItems(accounts.map { .account($0) })
DispatchQueue.main.async {
self.dataSource.apply(snapshot)
}
}
}
}
extension ProfileDirectoryViewController {
enum Section {
case featuredProfiles
}
enum Item: Hashable {
case account(Account)
func hash(into hasher: inout Hasher) {
guard case let .account(account) = self else { return }
hasher.combine(account.id)
}
}
}
extension ProfileDirectoryViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
}
extension ProfileDirectoryViewController: MenuPreviewProvider {
var navigationDelegate: TuskerNavigationDelegate? { self }
}
extension ProfileDirectoryViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath),
case let .account(account) = item else {
return
}
show(ProfileViewController(accountID: account.id, mastodonController: mastodonController), sender: nil)
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let item = dataSource.itemIdentifier(for: indexPath),
case let .account(account) = item else {
return nil
}
return UIContextMenuConfiguration(identifier: nil) {
return ProfileViewController(accountID: account.id, mastodonController: self.mastodonController)
} actionProvider: { (_) in
let actions = self.actionsForProfile(accountID: account.id, sourceView: self.collectionView.cellForItem(at: indexPath))
return UIMenu(children: actions)
}
}
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
if let viewController = animator.previewViewController {
animator.preferredCommitStyle = .pop
animator.addCompletion {
self.show(viewController, sender: nil)
}
}
}
}
extension ProfileDirectoryViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let item = dataSource.itemIdentifier(for: indexPath),
case let .account(account) = item,
let currentAccountID = mastodonController.accountInfo?.id else {
return []
}
let provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)]
}
}

View File

@ -0,0 +1,87 @@
//
// TrendingHashtagsViewController.swift
// Tusker
//
// Created by Shadowfacts on 2/6/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class TrendingHashtagsViewController: EnhancedTableViewController {
weak var mastodonController: MastodonController!
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
super.init(style: .grouped)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
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
// todo: enable drag
dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView) { (tableView, 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
}
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
let request = Client.getTrends(limit: 10)
mastodonController.run(request) { (response) in
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
guard case let .success(hashtags, _) = response,
hashtags.count > 0 else {
self.dataSource.apply(snapshot)
return
}
snapshot.appendSections([.trendingTags])
snapshot.appendItems(hashtags.map { .tag($0) })
self.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)
}
}
extension TrendingHashtagsViewController {
enum Section {
case trendingTags
}
enum Item: Hashable {
case tag(Hashtag)
}
}

View File

@ -54,7 +54,7 @@ class FastAccountSwitcherViewController: UIViewController {
view.isHidden = false
if UIAccessibility.prefersCrossFadeTransitionsBackwardsCompat {
if UIAccessibility.prefersCrossFadeTransitions {
view.alpha = 0
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
self.view.alpha = 1

View File

@ -87,8 +87,8 @@ class FastSwitchingAccountView: UIView {
let controller = MastodonController.getForAccount(account)
controller.getOwnAccount { [weak self] (result) in
guard let self = self, case let .success(account) = result else { return }
self.avatarRequest = ImageCache.avatars.get(account.avatar) { [weak avatarImageView] (data) in
guard let avatarImageView = avatarImageView, let data = data, let image = UIImage(data: data) else { return }
self.avatarRequest = ImageCache.avatars.get(account.avatar) { [weak avatarImageView] (_, image) in
guard let avatarImageView = avatarImageView, let image = image else { return }
DispatchQueue.main.async {
avatarImageView.image = image
}

View File

@ -85,19 +85,20 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
overrideUserInterfaceStyle = .dark
view.backgroundColor = .black
if let data = cache.get(url) {
createLargeImage(data: data, url: url)
// always load full resolution from disk for large image, in case the cache is scaled
if let entry = cache.get(url, loadOriginal: true) {
createLargeImage(data: entry.data, image: entry.image, url: url)
} else {
createPreview()
loadingVC = LoadingViewController()
embedChild(loadingVC!)
imageRequest = cache.get(url) { [weak self] (data) in
imageRequest = cache.get(url, loadOriginal: true) { [weak self] (data, image) in
guard let self = self else { return }
self.imageRequest = nil
DispatchQueue.main.async {
self.loadingVC?.removeViewAndController()
self.createLargeImage(data: data!, url: self.url)
self.createLargeImage(data: data!, image: image!, url: self.url)
}
}
}
@ -115,20 +116,13 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
}
}
private func createLargeImage(data: Data, url: URL) {
private func createLargeImage(data: Data?, image: UIImage, url: URL) {
guard !loaded else { return }
loaded = true
let image: UIImage?
if Preferences.shared.grayscaleImages {
image = ImageGrayscalifier.convert(url: url, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
if let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) {
let gifData = url.pathExtension == "gif" ? data : nil
createLargeImage(image: image, gifData: gifData)
createLargeImage(image: transformedImage, gifData: gifData)
}
}

View File

@ -38,7 +38,7 @@ extension LargeImageAnimatableViewController {
class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
if UIAccessibility.prefersCrossFadeTransitionsBackwardsCompat {
if UIAccessibility.prefersCrossFadeTransitions {
return 0.2
} else {
return 0.4
@ -51,7 +51,7 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
return
}
if UIAccessibility.prefersCrossFadeTransitionsBackwardsCompat {
if UIAccessibility.prefersCrossFadeTransitions {
animateCrossFadeTransition(using: transitionContext)
return
}

View File

@ -27,7 +27,7 @@ class LargeImageShrinkAnimationController: NSObject, UIViewControllerAnimatedTra
return
}
if UIAccessibility.prefersCrossFadeTransitionsBackwardsCompat && !transitionContext.isInteractive {
if UIAccessibility.prefersCrossFadeTransitions && !transitionContext.isInteractive {
animateCrossFadeTransition(using: transitionContext)
return
}

View File

@ -47,6 +47,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
guard case let .account(id) = item else { fatalError() }
let cell = tableView.dequeueReusableCell(withIdentifier: "accountCell", for: indexPath) as! AccountTableViewCell
cell.delegate = self
cell.updateUI(accountID: id)
return cell
})
@ -171,3 +172,7 @@ extension EditListAccountsViewController: SearchResultsViewControllerDelegate {
}
}
}
extension EditListAccountsViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
}

View File

@ -37,7 +37,7 @@ class AccountSwitchingContainerViewController: UIViewController {
embedChild(newRoot)
if direction != .none {
if UIAccessibility.prefersCrossFadeTransitionsBackwardsCompat {
if UIAccessibility.prefersCrossFadeTransitions {
newRoot.view.alpha = 0
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseInOut) {

View File

@ -9,13 +9,11 @@
import UIKit
import Pachyderm
@available(iOS 14.0, *)
protocol MainSidebarViewControllerDelegate: class {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)
}
@available(iOS 14.0, *)
class MainSidebarViewController: UIViewController {
private weak var mastodonController: MastodonController!
@ -34,7 +32,7 @@ class MainSidebarViewController: UIViewController {
}
var exploreTabItems: [Item] {
var items: [Item] = [.search, .bookmarks]
var items: [Item] = [.search, .bookmarks, .trendingTags, .profileDirectory]
let snapshot = dataSource.snapshot()
for case let .list(list) in snapshot.itemIdentifiers(inSection: .lists) {
items.append(.list(list))
@ -88,6 +86,10 @@ class MainSidebarViewController: UIViewController {
applyInitialSnapshot()
if mastodonController.instance == nil {
mastodonController.getOwnInstance(completion: self.ownInstanceLoaded(_:))
}
select(item: .tab(.timelines), animated: false)
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil)
@ -95,6 +97,8 @@ class MainSidebarViewController: UIViewController {
}
func select(item: Item, animated: Bool) {
// ensure view is loaded, since dataSource is created in viewDidLoad
loadViewIfNeeded()
guard let indexPath = dataSource.indexPath(for: item) else { return }
collectionView.selectItem(at: indexPath, animated: animated, scrollPosition: .top)
itemLastSelectedTimestamps[item] = Date()
@ -130,7 +134,7 @@ class MainSidebarViewController: UIViewController {
private func applyInitialSnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(Section.allCases)
snapshot.appendSections(Section.allCases.filter { $0 != .discover })
snapshot.appendItems([
.tab(.timelines),
.tab(.notifications),
@ -141,6 +145,13 @@ class MainSidebarViewController: UIViewController {
snapshot.appendItems([
.tab(.compose)
], toSection: .compose)
if case .mastodon = mastodonController.instance?.instanceType {
snapshot.insertSections([.discover], afterSection: .compose)
snapshot.appendItems([
.trendingTags,
.profileDirectory,
], toSection: .discover)
}
dataSource.apply(snapshot, animatingDifferences: false)
reloadLists()
@ -148,6 +159,18 @@ class MainSidebarViewController: UIViewController {
reloadSavedInstances()
}
private func ownInstanceLoaded(_ instance: Instance) {
var snapshot = self.dataSource.snapshot()
if case .mastodon = mastodonController.instance?.instanceType {
snapshot.insertSections([.discover], afterSection: .compose)
snapshot.appendItems([
.trendingTags,
.profileDirectory,
], toSection: .discover)
}
dataSource.apply(snapshot, animatingDifferences: false)
}
private func reloadLists() {
let request = Client.getLists()
mastodonController.run(request) { [weak self] (response) in
@ -159,29 +182,47 @@ class MainSidebarViewController: UIViewController {
exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader)
exploreSnapshot.append([.addList], to: .listsHeader)
DispatchQueue.main.async {
self.dataSource.apply(exploreSnapshot, to: .lists)
let selected = self.collectionView.indexPathsForSelectedItems?.first
self.dataSource.apply(exploreSnapshot, to: .lists) {
if let selected = selected {
self.collectionView.selectItem(at: selected, animated: false, scrollPosition: .centeredVertically)
}
}
}
}
}
@objc private func reloadSavedHashtags() {
let selected = collectionView.indexPathsForSelectedItems?.first
var hashtagsSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
hashtagsSnapshot.append([.savedHashtagsHeader])
hashtagsSnapshot.expand([.savedHashtagsHeader])
let sortedHashtags = SavedDataManager.shared.sortedHashtags(for: mastodonController.accountInfo!)
hashtagsSnapshot.append(sortedHashtags.map { .savedHashtag($0) }, to: .savedHashtagsHeader)
hashtagsSnapshot.append([.addSavedHashtag], to: .savedHashtagsHeader)
self.dataSource.apply(hashtagsSnapshot, to: .savedHashtags, animatingDifferences: false)
self.dataSource.apply(hashtagsSnapshot, to: .savedHashtags, animatingDifferences: false) {
if let selected = selected {
self.collectionView.selectItem(at: selected, animated: false, scrollPosition: .centeredVertically)
}
}
}
@objc private func reloadSavedInstances() {
let selected = collectionView.indexPathsForSelectedItems?.first
var instancesSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
instancesSnapshot.append([.savedInstancesHeader])
instancesSnapshot.expand([.savedInstancesHeader])
let sortedInstances = SavedDataManager.shared.savedInstances(for: mastodonController.accountInfo!)
instancesSnapshot.append(sortedInstances.map { .savedInstance($0) }, to: .savedInstancesHeader)
instancesSnapshot.append([.addSavedInstance], to: .savedInstancesHeader)
self.dataSource.apply(instancesSnapshot, to: .savedInstances, animatingDifferences: false)
self.dataSource.apply(instancesSnapshot, to: .savedInstances, animatingDifferences: false) {
if let selected = selected {
self.collectionView.selectItem(at: selected, animated: false, scrollPosition: .centeredVertically)
}
}
}
// todo: deduplicate with ExploreViewController
@ -250,11 +291,11 @@ class MainSidebarViewController: UIViewController {
}
@available(iOS 14.0, *)
extension MainSidebarViewController {
enum Section: Int, Hashable, CaseIterable {
case tabs
case compose
case discover
case lists
case savedHashtags
case savedInstances
@ -262,6 +303,7 @@ extension MainSidebarViewController {
enum Item: Hashable {
case tab(MainTabBarViewController.Tab)
case search, bookmarks
case trendingTags, profileDirectory
case listsHeader, list(List), addList
case savedHashtagsHeader, savedHashtag(Hashtag), addSavedHashtag
case savedInstancesHeader, savedInstance(URL), addSavedInstance
@ -274,6 +316,10 @@ extension MainSidebarViewController {
return "Search"
case .bookmarks:
return "Bookmarks"
case .trendingTags:
return "Trending Hashtags"
case .profileDirectory:
return "Profile Directory"
case .listsHeader:
return "Lists"
case let .list(list):
@ -303,6 +349,10 @@ extension MainSidebarViewController {
return "magnifyingglass"
case .bookmarks:
return "bookmark"
case .trendingTags:
return "arrow.up.arrow.down"
case .profileDirectory:
return "person.2.fill"
case .list(_):
return "list.bullet"
case .savedHashtag(_):
@ -360,7 +410,6 @@ fileprivate extension MainTabBarViewController.Tab {
}
}
@available(iOS 14.0, *)
extension MainSidebarViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
previouslySelectedItem = selectedItem
@ -395,7 +444,6 @@ extension MainSidebarViewController: UICollectionViewDelegate {
}
}
@available(iOS 14.0, *)
extension MainSidebarViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let item = dataSource.itemIdentifier(for: indexPath),
@ -408,7 +456,6 @@ extension MainSidebarViewController: UICollectionViewDragDelegate {
}
}
@available(iOS 14.0, *)
extension MainSidebarViewController: InstanceTimelineViewControllerDelegate {
func didSaveInstance(url: URL) {
dismiss(animated: true) {

View File

@ -8,7 +8,6 @@
import UIKit
@available(iOS 14.0, *)
class MainSplitViewController: UISplitViewController {
weak var mastodonController: MastodonController!
@ -103,7 +102,6 @@ class MainSplitViewController: UISplitViewController {
}
@available(iOS 14.0, *)
extension MainSplitViewController: UISplitViewControllerDelegate {
/// Transfer the navigation stack for a sidebar item to a destination navgiation controller.
/// - Parameter dropFirst: Remove the first view controller from the item's navigation stack before transferring.
@ -205,7 +203,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
tabBarViewController.select(tab: .explore)
case .bookmarks, .list(_), .savedHashtag(_), .savedInstance(_):
case .bookmarks, .trendingTags, .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.
@ -279,6 +277,10 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
exploreItem = .savedHashtag(hashtagVC.hashtag)
} else if let instanceVC = tabNavigationStack[1] as? InstanceTimelineViewController {
exploreItem = .savedInstance(instanceVC.instanceURL)
} else if tabNavigationStack[1] is TrendingHashtagsViewController {
exploreItem = .trendingTags
} else if tabNavigationStack[1] is ProfileDirectoryViewController {
exploreItem = .profileDirectory
}
transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: 1, prepend: toPrepend)
@ -307,7 +309,6 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
}
}
@available(iOS 14.0, *)
extension MainSplitViewController: MainSidebarViewControllerDelegate {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) {
presentCompose()
@ -322,7 +323,6 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate {
}
}
@available(iOS 14.0, *)
fileprivate extension MainSidebarViewController.Item {
func createRootViewController(_ mastodonController: MastodonController) -> UIViewController? {
switch self {
@ -332,19 +332,22 @@ fileprivate extension MainSidebarViewController.Item {
return SearchViewController(mastodonController: mastodonController)
case .bookmarks:
return BookmarksTableViewController(mastodonController: mastodonController)
case .trendingTags:
return TrendingHashtagsViewController(mastodonController: mastodonController)
case .profileDirectory:
return ProfileDirectoryViewController(mastodonController: mastodonController)
case let .list(list):
return ListTimelineViewController(for: list, mastodonController: mastodonController)
case let .savedHashtag(hashtag):
return HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController)
case let .savedInstance(url):
return InstanceTimelineViewController(for: url, parentMastodonController: mastodonController)
default:
case .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:
return nil
}
}
}
@available(iOS 14.0, *)
extension MainSplitViewController: TuskerRootViewController {
@objc func presentCompose() {
let vc = ComposeHostingController(mastodonController: mastodonController)
@ -381,7 +384,6 @@ extension MainSplitViewController: TuskerRootViewController {
}
}
@available(iOS 14.0, *)
extension MainSplitViewController: BackgroundableViewController {
func sceneDidEnterBackground() {
if traitCollection.horizontalSizeClass == .compact {

View File

@ -98,7 +98,9 @@ class NotificationsTableViewController: TimelineLikeTableViewController<Notifica
mastodonController.run(request) { (response) in
guard case let .success(newNotifications, pagination) = response else { fatalError() }
self.newer = pagination?.newer
if let newer = pagination?.newer {
self.newer = newer
}
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
@ -211,7 +213,7 @@ extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
for notification in item(for: indexPath).notifications {
_ = ImageCache.avatars.get(notification.account.avatar, completion: nil)
ImageCache.avatars.fetchIfNotCached(notification.account.avatar)
}
}
}

View File

@ -17,7 +17,7 @@ struct AdvancedPrefsView : View {
automationSection
cachingSection
}
.insetOrGroupedListStyle()
.listStyle(InsetGroupedListStyle())
.navigationBarTitle(Text("Advanced"))
}

View File

@ -33,7 +33,7 @@ struct AppearancePrefsView : View {
accountsSection
postsSection
}
.insetOrGroupedListStyle()
.listStyle(InsetGroupedListStyle())
.navigationBarTitle(Text("Appearance"))
}

View File

@ -16,7 +16,7 @@ struct BehaviorPrefsView: View {
linksSection
contentWarningsSection
}
.insetOrGroupedListStyle()
.listStyle(InsetGroupedListStyle())
.navigationBarTitle(Text("Behavior"))
}

View File

@ -17,7 +17,7 @@ struct ComposingPrefsView: View {
composingSection
replyingSection
}
.insetOrGroupedListStyle()
.listStyle(InsetGroupedListStyle())
.navigationBarTitle("Composing")
}

View File

@ -38,8 +38,7 @@ struct LocalAccountAvatarView: View {
let controller = MastodonController.getForAccount(localAccountInfo)
controller.getOwnAccount { (result) in
guard case let .success(account) = result else { return }
_ = ImageCache.avatars.get(account.avatar) { (data) in
if let data = data, let image = UIImage(data: data) {
_ = ImageCache.avatars.get(account.avatar) { (_, image) in
DispatchQueue.main.async {
self.avatarImage = image
}
@ -47,7 +46,6 @@ struct LocalAccountAvatarView: View {
}
}
}
}
//struct LocalAccountAvatarView_Previews: PreviewProvider {
// static var previews: some View {

View File

@ -15,7 +15,7 @@ struct MediaPrefsView: View {
List {
viewingSection
}
.insetOrGroupedListStyle()
.listStyle(InsetGroupedListStyle())
.navigationBarTitle("Media")
}

View File

@ -89,7 +89,7 @@ struct PreferencesView: View {
}
}
}
.insetOrGroupedListStyle()
.listStyle(InsetGroupedListStyle())
.navigationBarTitle(Text("Preferences"), displayMode: .inline)
// }
}
@ -99,17 +99,6 @@ struct PreferencesView: View {
}
}
extension View {
@ViewBuilder
func insetOrGroupedListStyle() -> some View {
if #available(iOS 14.0, *) {
self.listStyle(InsetGroupedListStyle())
} else {
self.listStyle(GroupedListStyle())
}
}
}
#if DEBUG
struct PreferencesView_Previews : PreviewProvider {
static var previews: some View {

View File

@ -14,7 +14,7 @@ struct SilentActionPrefs : View {
List(Array(preferences.silentActions.keys), id: \.self) { source in
SilentActionPermissionCell(source: source)
}
.insetOrGroupedListStyle()
.listStyle(InsetGroupedListStyle())
// .navigationBarTitle("Silent Action Permissions")
// see FB6838291
}

View File

@ -17,7 +17,7 @@ struct WellnessPrefsView: View {
notificationsMode
grayscaleImages
}
.insetOrGroupedListStyle()
.listStyle(InsetGroupedListStyle())
.navigationBarTitle(Text("Digital Wellness"))
}

View File

@ -43,17 +43,10 @@ class MyProfileViewController: ProfileViewController {
private func setAvatarTabBarImage<Account: AccountProtocol>(account: Account) {
let avatarURL = account.avatar
_ = ImageCache.avatars.get(avatarURL, completion: { [weak self] (data) in
guard let self = self, let data = data else { return }
let maybeGrayscale: UIImage?
if Preferences.shared.grayscaleImages {
maybeGrayscale = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
maybeGrayscale = UIImage(data: data)
}
guard let image = maybeGrayscale else {
_ = ImageCache.avatars.get(avatarURL, completion: { [weak self] (_, image) in
guard let self = self,
let image = image,
let maybeGrayscale = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
return
}
@ -63,7 +56,7 @@ class MyProfileViewController: ProfileViewController {
let tabBarImage = UIGraphicsImageRenderer(size: size).image { (_) in
let radius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
UIBezierPath(roundedRect: rect, cornerRadius: radius).addClip()
image.draw(in: rect)
maybeGrayscale.draw(in: rect)
}
let alwaysOriginalImage = tabBarImage.withRenderingMode(.alwaysOriginal)
self.tabBarItem.image = alwaysOriginalImage

View File

@ -145,7 +145,9 @@ class ProfileStatusesViewController: TimelineLikeTableViewController<TimelineEnt
return
}
self.newer = pagination?.newer
if let newer = pagination?.newer {
self.newer = newer
}
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
completion(statuses.map { ($0.id, .unknown) })
@ -182,7 +184,8 @@ class ProfileStatusesViewController: TimelineLikeTableViewController<TimelineEnt
}
self.mastodonController.persistentContainer.addAll(statuses: newPinnedStatues) {
let oldPinnedStatuses = self.sections[0]
// if the user refreshes before the initial pinned statuses request completes, self.sections will be empty
let oldPinnedStatuses = self.sections.isEmpty ? [] : self.sections[0]
let pinnedStatues = newPinnedStatues.map { (status) -> TimelineEntry in
let state: StatusState
if let (_, oldState) = oldPinnedStatuses.first(where: { $0.id == status.id }) {
@ -193,12 +196,12 @@ class ProfileStatusesViewController: TimelineLikeTableViewController<TimelineEnt
return (status.id, state)
}
DispatchQueue.main.async {
UIView.performWithoutAnimation {
if self.sections.count < 1 {
self.sections.append(pinnedStatues)
self.tableView.insertSections(IndexSet(integer: 0), with: .none)
} else {
self.sections[0] = pinnedStatues
}
UIView.performWithoutAnimation {
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
}
}
@ -206,6 +209,7 @@ class ProfileStatusesViewController: TimelineLikeTableViewController<TimelineEnt
}
}
}
}
// MARK: - UITableViewDatasource
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
@ -238,33 +242,20 @@ extension ProfileStatusesViewController: StatusTableViewCellDelegate {
}
}
extension ProfileStatusesViewController: UITableViewDataSourcePrefetching {
extension ProfileStatusesViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
let statusID = item(for: indexPath).id
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
continue
}
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments where attachment.kind == .image {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
}
}
let ids = indexPaths.map { item(for: $0).id }
prefetchStatuses(with: ids)
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
let statusID = item(for: indexPath).id
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
continue
}
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments where attachment.kind == .image {
ImageCache.avatars.cancelWithoutCallback(attachment.url)
}
let ids: [String] = indexPaths.compactMap {
guard $0.section < sections.count,
$0.row < sections[$0.section].count else {
return nil
}
return item(for: $0).id
}
cancelPrefetchingStatuses(with: ids)
}
}

View File

@ -69,13 +69,11 @@ class ProfileViewController: UIPageViewController {
view.backgroundColor = .systemBackground
let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning))
if #available(iOS 14.0, *) {
composeButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: [
UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
self.composeDirectMentioning()
})
])
}
navigationItem.rightBarButtonItem = composeButton
headerView = ProfileHeaderView.create()

View File

@ -132,6 +132,7 @@ class SearchResultsViewController: EnhancedTableViewController {
activityIndicator.isHidden = false
activityIndicator.startAnimating()
let resultTypes = self.resultTypes
let request = Client.search(query: query, types: resultTypes, resolve: true, limit: 10)
mastodonController.run(request) { (response) in
guard case let .success(results, _) = response else { fatalError() }
@ -161,16 +162,16 @@ class SearchResultsViewController: EnhancedTableViewController {
}
}
if !results.accounts.isEmpty {
if !results.accounts.isEmpty && (resultTypes == nil || resultTypes!.contains(.accounts)) {
snapshot.appendSections([.accounts])
snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts)
addAccounts(results.accounts)
}
if !results.hashtags.isEmpty {
if !results.hashtags.isEmpty && (resultTypes == nil || resultTypes!.contains(.hashtags)) {
snapshot.appendSections([.hashtags])
snapshot.appendItems(results.hashtags.map { .hashtag($0) }, toSection: .hashtags)
}
if !results.statuses.isEmpty {
if !results.statuses.isEmpty && (resultTypes == nil || resultTypes!.contains(.statuses)) {
snapshot.appendSections([.statuses])
snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
addStatuses(results.statuses)

View File

@ -113,7 +113,11 @@ class TimelineTableViewController: TimelineLikeTableViewController<TimelineEntry
mastodonController.run(request) { (response) in
guard case let .success(statuses, pagination) = response else { fatalError() }
self.newer = pagination?.newer
// if there are no new statuses, pagination is nil
// if we were to then overwrite self.newer, future refreshes would fail
if let newer = pagination?.newer {
self.newer = newer
}
self.mastodonController?.persistentContainer.addAll(statuses: statuses) {
completion(statuses.map { ($0.id, .unknown) })
@ -146,32 +150,20 @@ extension TimelineTableViewController: StatusTableViewCellDelegate {
}
}
extension TimelineTableViewController: UITableViewDataSourcePrefetching {
extension TimelineTableViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let status = mastodonController.persistentContainer.status(for: item(for: indexPath).id) else {
continue
}
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments where attachment.kind == .image {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
}
}
let ids = indexPaths.map { item(for: $0).id }
prefetchStatuses(with: ids)
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
// todo: this means when removing cells, we can't cancel prefetching
// is this an issue?
guard indexPath.section < sections.count,
indexPath.row < sections[indexPath.section].count,
let status = mastodonController.persistentContainer.status(for: item(for: indexPath).id) else {
continue
}
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments where attachment.kind == .image {
ImageCache.attachments.cancelWithoutCallback(attachment.url)
}
let ids: [String] = indexPaths.compactMap {
guard $0.section < sections.count,
$0.row < sections[$0.section].count else {
return nil
}
return item(for: $0).id
}
cancelPrefetchingStatuses(with: ids)
}
}

View File

@ -40,7 +40,7 @@ extension MenuPreviewProvider {
guard mastodonController.loggedIn else {
return [
openInSafariAction(url: account.url),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView)
})
@ -54,14 +54,14 @@ extension MenuPreviewProvider {
}),
]
if accountID != mastodonController.account.id,
#available(iOS 14.0, *) {
if accountID != mastodonController.account.id {
actionsSection.append(UIDeferredMenuElement({ (elementHandler) in
guard let mastodonController = self.mastodonController else {
elementHandler([])
return
}
let request = Client.getRelationships(accounts: [account.id])
// talk about callback hell :/
mastodonController.run(request) { [weak self] (response) in
if let self = self,
case let .success(results, _) = response,
@ -89,7 +89,7 @@ extension MenuPreviewProvider {
let shareSection = [
openInSafariAction(url: account.url),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView)
})
@ -104,7 +104,7 @@ extension MenuPreviewProvider {
func actionsForURL(_ url: URL, sourceView: UIView?) -> [UIAction] {
return [
openInSafariAction(url: url),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forURL: url, sourceView: sourceView)
})
@ -139,7 +139,7 @@ extension MenuPreviewProvider {
guard mastodonController.loggedIn else {
return [
openInSafariAction(url: status.url!),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView)
})
@ -190,7 +190,7 @@ extension MenuPreviewProvider {
var shareSection = [
openInSafariAction(url: status.url!),
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
createAction(identifier: "share", title: "Share...", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView)
}),
@ -223,8 +223,8 @@ extension MenuPreviewProvider {
}
private func openInSafariAction(url: URL) -> UIAction {
return createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { (_) in
self.navigationDelegate?.selected(url: url, allowUniversalLinks: false)
return createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { [weak self] (_) in
self?.navigationDelegate?.selected(url: url, allowUniversalLinks: false)
})
}

View File

@ -0,0 +1,53 @@
//
// StatusTablePrefetching.swift
// Tusker
//
// Created by Shadowfacts on 1/18/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
protocol StatusTablePrefetching: TuskerNavigationDelegate {
}
extension StatusTablePrefetching {
func prefetchStatuses(with ids: [String]) {
let context = apiController.persistentContainer.prefetchBackgroundContext
context.perform {
guard let statuses = getStatusesWith(ids: ids, in: context) else {
return
}
for status in statuses {
ImageCache.avatars.fetchIfNotCached(status.account.avatar)
for attachment in status.attachments where attachment.kind == .image {
ImageCache.attachments.fetchIfNotCached(attachment.url)
}
}
}
}
func cancelPrefetchingStatuses(with ids: [String]) {
let context = apiController.persistentContainer.prefetchBackgroundContext
context.perform {
guard let statuses = getStatusesWith(ids: ids, in: context) else {
return
}
for status in statuses {
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments where attachment.kind == .image {
ImageCache.attachments.cancelWithoutCallback(attachment.url)
}
}
}
}
}
fileprivate func getStatusesWith(ids: [String], in context: NSManagedObjectContext) -> [StatusMO]? {
let request: NSFetchRequest<StatusMO> = StatusMO.fetchRequest()
request.predicate = NSPredicate(format: "id IN %@", ids)
return try? context.fetch(request)
}

View File

@ -117,7 +117,9 @@ class TimelineLikeTableViewController<Item>: EnhancedTableViewController, Refres
}
private func pruneOffscreenRows() {
guard let lastVisibleRow = lastLastVisibleRow else {
guard let lastVisibleRow = lastLastVisibleRow,
// never remove the last section
sections.count - headerSectionsCount() > 1 else {
return
}
let lastSectionIndex = sections.count - 1
@ -134,11 +136,11 @@ class TimelineLikeTableViewController<Item>: EnhancedTableViewController, Refres
}
willRemoveRows(at: indexPathsToRemove)
sections.removeSubrange(sectionsToRemove)
UIView.performWithoutAnimation {
tableView.deleteSections(IndexSet(sectionsToRemove), with: .none)
}
sections.removeSubrange(sectionsToRemove)
} else if lastVisibleRow.section == lastSectionIndex {
let lastSection = sections.last!
let lastRowIndex = lastSection.count - 1
@ -146,7 +148,7 @@ class TimelineLikeTableViewController<Item>: EnhancedTableViewController, Refres
if lastVisibleRow.row < lastRowIndex - pageSize {
// if there are more than pageSize rows in the current section below the last visible one
let rowIndicesInLastSectionToRemove = (lastVisibleRow.row + 20)..<lastSection.count
let rowIndicesInLastSectionToRemove = (lastVisibleRow.row + pageSize)..<lastSection.count
let indexPathsToRemove = rowIndicesInLastSectionToRemove.map {
IndexPath(row: $0, section: lastSectionIndex)

View File

@ -8,7 +8,6 @@
import UIKit
@available(iOS 13.4, *)
class TrackpadScrollGestureRecognizer: UIPanGestureRecognizer {
override init(target: Any?, action: Selector?) {

View File

@ -12,6 +12,8 @@ import Pachyderm
protocol TuskerNavigationDelegate: UIViewController {
var apiController: MastodonController { get }
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController
}
extension TuskerNavigationDelegate {
@ -64,19 +66,16 @@ extension TuskerNavigationDelegate {
}
}
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController {
return ConversationTableViewController(for: mainStatusID, state: state, mastodonController: apiController)
}
func selected(status statusID: String) {
self.selected(status: statusID, state: .unknown)
}
func selected(status statusID: String, state: StatusState) {
// todo: is this necessary? should the conversation main status cell prevent this
// don't open if the conversation is the same as the current one
if let conversationController = self as? ConversationTableViewController,
conversationController.mainStatusID == statusID {
return
}
show(ConversationTableViewController(for: statusID, state: state, mastodonController: apiController), sender: self)
show(conversation(mainStatusID: statusID, state: state), sender: self)
}
func compose(editing draft: Draft) {
@ -126,41 +125,13 @@ extension TuskerNavigationDelegate {
guard let status = apiController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID)") }
guard let url = status.url else { fatalError("Missing url for status \(statusID)") }
// on iOS 14+, all these custom actions are in the context menu and don't need to be in the share sheet
if #available(iOS 14.0, *) {
return UIActivityViewController(activityItems: [url, StatusActivityItemSource(status)], applicationActivities: nil)
} else {
var customActivites: [UIActivity] = [
OpenInSafariActivity(),
(status.bookmarked ?? false) ? UnbookmarkStatusActivity() : BookmarkStatusActivity(),
status.muted ? UnmuteConversationActivity() : MuteConversationActivity(),
]
if apiController.account != nil, status.account.id == apiController.account.id {
let pinned = status.pinned ?? false
customActivites.insert(pinned ? UnpinStatusActivity() : PinStatusActivity(), at: 1)
}
let activityController = UIActivityViewController(activityItems: [url, StatusActivityItemSource(status)], applicationActivities: customActivites)
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(navigator: self, url: url)
return activityController
}
}
private func moreOptions(forAccount accountID: String) -> UIActivityViewController {
guard let account = apiController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID)") }
if #available(iOS 14.0, *) {
return UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: nil)
} else {
let customActivities: [UIActivity] = [
OpenInSafariActivity(),
]
let activityController = UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: customActivities)
activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(navigator: self, url: account.url)
return activityController
}
}
func showMoreOptions(forStatus statusID: String, sourceView: UIView?) {

View File

@ -2,9 +2,17 @@
/// From https://github.com/woltapp/blurhash/blob/b23214ddcab803fe1ec9a3e6b20558caf33a23a5/Swift/BlurHashDecode.swift
import UIKit
// blurhashes are disabled in debug builds because this code is hideously slow when not optimized by the compiler
#if DEBUG
fileprivate let blurHashesEnabled = ProcessInfo.processInfo.environment.keys.contains("DEBUG_BLUR_HASH")
#else
fileprivate let blurHashesEnabled = true
#endif
extension UIImage {
public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) {
guard blurHash.count >= 6 else { return nil }
guard blurHashesEnabled,
blurHash.count >= 6 else { return nil }
let sizeFlag = String(blurHash[0]).decode83()
let numY = (sizeFlag / 9) + 1

View File

@ -63,19 +63,16 @@ class AccountTableViewCell: UITableViewCell {
let accountID = self.accountID
let avatarURL = account.avatar
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (data) in
guard let self = self, let data = data, self.accountID == accountID else { return }
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self else { return }
self.avatarRequest = nil
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
guard let image = image,
self.accountID == accountID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else { return }
DispatchQueue.main.async {
self.avatarImageView.image = image
self.avatarImageView.image = transformedImage
}
}

View File

@ -69,11 +69,11 @@ class LargeAccountDetailView: UIView {
displayNameLabel.updateForAccountDisplayName(account: account)
usernameLabel.text = "@\(account.acct)"
avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in
guard let self = self, let data = data else { return }
avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (_, image) in
guard let self = self, let image = image else { return }
self.avatarRequest = nil
DispatchQueue.main.async {
self.avatarImageView.image = UIImage(data: data)
self.avatarImageView.image = image
}
}
}

View File

@ -24,18 +24,11 @@ struct AccountDisplayNameLabel<Account: AccountProtocol>: View {
}
var body: some View {
if #available(iOS 14.0, *) {
text
.font(.system(size: CGFloat(fontSize), weight: .semibold))
.onAppear(perform: self.loadEmojis)
} else {
text
.font(.system(size: CGFloat(fontSize), weight: .semibold))
}
}
// embedding Image inside Text is only available on iOS 14
@available(iOS 14.0, *)
private func loadEmojis() {
let fullRange = NSRange(account.displayName.startIndex..., in: account.displayName)
let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange)
@ -54,9 +47,9 @@ struct AccountDisplayNameLabel<Account: AccountProtocol>: View {
}
group.enter()
let request = ImageCache.emojis.get(emoji.url) { (data) in
let request = ImageCache.emojis.get(emoji.url) { (_, image) in
defer { group.leave() }
guard let data = data, let image = UIImage(data: data) else { return }
guard let image = image else { return }
let size = CGSize(width: fontSize, height: fontSize)
let renderer = UIGraphicsImageRenderer(size: size)

View File

@ -1,23 +0,0 @@
//
// ActivityIndicatorView.swift
// Tusker
//
// Created by Shadowfacts on 8/29/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct ActivityIndicatorView: UIViewRepresentable {
typealias UIViewType = UIActivityIndicatorView
func makeUIView(context: Context) -> UIActivityIndicatorView {
let view = UIActivityIndicatorView()
view.startAnimating()
return view
}
func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) {
}
}

View File

@ -159,7 +159,7 @@ class AttachmentView: UIImageView, GIFAnimatable {
func loadImage() {
let attachmentURL = attachment.url
attachmentRequest = ImageCache.attachments.get(attachmentURL) { [weak self] (data) in
attachmentRequest = ImageCache.attachments.get(attachmentURL) { [weak self] (data, _) in
guard let self = self, let data = data else { return }
self.attachmentRequest = nil
if self.attachment.url.pathExtension == "gif" {

View File

@ -43,20 +43,13 @@ extension BaseEmojiLabel {
foundEmojis = true
group.enter()
let request = ImageCache.emojis.get(emoji.url) { (data) in
let request = ImageCache.emojis.get(emoji.url) { (_, image) in
defer { group.leave() }
guard let data = data else {
guard let image = image,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: emoji.url, image: image) else {
return
}
let image: UIImage?
if Preferences.shared.grayscaleImages {
image = ImageGrayscalifier.convert(url: emoji.url, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
emojiImages[emoji.shortcode] = image
}
emojiImages[emoji.shortcode] = transformedImage
}
if let request = request {
emojiRequests.append(request)

View File

@ -63,20 +63,13 @@ class ContentTextView: LinkTextView {
for emoji in emojis {
group.enter()
_ = ImageCache.emojis.get(emoji.url) { (data) in
_ = ImageCache.emojis.get(emoji.url) { (_, image) in
defer { group.leave() }
guard let data = data else {
guard let image = image,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: emoji.url, image: image) else {
return
}
let image: UIImage?
if Preferences.shared.grayscaleImages {
image = ImageGrayscalifier.convert(url: emoji.url, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
emojiImages[emoji.shortcode] = image
}
emojiImages[emoji.shortcode] = transformedImage
}
}

View File

@ -33,16 +33,12 @@ struct CustomEmojiImageView: View {
}
private func loadImage() {
request = ImageCache.emojis.get(emoji.url) { (data) in
if let data = data, let image = UIImage(data: data) {
request = ImageCache.emojis.get(emoji.url) { (_, image) in
DispatchQueue.main.async {
self.request = nil
if let image = image {
self.image = image
}
} else {
DispatchQueue.main.async {
self.request = nil
}
}
}
}

View File

@ -0,0 +1,133 @@
//
// HashtagHistoryView.swift
// Tusker
//
// Created by Shadowfacts on 1/24/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class HashtagHistoryView: UIView {
private var history: [Hashtag.History]?
private let curveRadius: CGFloat = 10
override func layoutSubviews() {
super.layoutSubviews()
createLayers()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
createLayers()
}
func setHistory(_ history: [Hashtag.History]?) {
if let history = history {
self.history = history.sorted(by: { $0.day < $1.day })
} else {
self.history = nil
}
createLayers()
}
private func createLayers() {
guard let history = history,
history.count >= 2 else { return }
let maxUses = history.max(by: { $0.uses < $1.uses })!.uses
// remove old layers if this view is being re-used
layer.sublayers?.forEach { $0.removeFromSuperlayer() }
let path = UIBezierPath()
let widthStep = bounds.width / CGFloat(history.count - 1)
let points: [CGPoint] = history.enumerated().map { (index, entry) in
let x = CGFloat(index) * widthStep
let yFrac = CGFloat(entry.uses) / CGFloat(maxUses)
let y = (1 - yFrac) * bounds.height
return CGPoint(x: x, y: y)
}
var gapStartPoints = [CGPoint]()
var gapEndPoints = [CGPoint]()
for (index, point) in points.enumerated().dropFirst().dropLast() {
let prev = points[index - 1]
let next = points[index + 1]
let a = atan((point.y - prev.y) / widthStep)
let b = atan((next.y - point.y) / widthStep)
let innerAngle = .pi - a - b
let gapDistance = curveRadius / sin(innerAngle / 2)
let x1 = point.x - cos(a) * gapDistance
let y1 = point.y - sin(a) * gapDistance
gapStartPoints.append(CGPoint(x: x1, y: y1))
let x2 = point.x + cos(b) * gapDistance
let y2 = point.y + sin(b) * gapDistance
gapEndPoints.append(CGPoint(x: x2, y: y2))
}
path.move(to: points.first!)
for (index, point) in points.dropFirst().dropLast().enumerated() {
path.addLine(to: gapStartPoints[index])
path.addQuadCurve(to: gapEndPoints[index], controlPoint: point)
}
path.addLine(to: points.last!)
let borderLayer = CAShapeLayer()
// copy the border path so we can continue mutating the UIBezierPath to create the fill path
borderLayer.path = path.cgPath.copy()!
borderLayer.strokeColor = tintColor.cgColor
borderLayer.fillColor = nil
borderLayer.lineWidth = 2
borderLayer.lineCap = .round
path.addLine(to: CGPoint(x: bounds.width, y: bounds.height))
path.addLine(to: CGPoint(x: 0, y: bounds.height))
path.addLine(to: points.first!)
let fillLayer = CAShapeLayer()
fillLayer.path = path.cgPath
let fillColor = self.fillColor()
fillLayer.strokeColor = fillColor
fillLayer.fillColor = fillColor
fillLayer.lineWidth = 2
layer.addSublayer(fillLayer)
layer.addSublayer(borderLayer)
}
// The non-transparent fill color.
// We blend with the view's background color ourselves so that final color is non-transparent,
// otherwise when the fill layer's border and fill overlap, there's a visibly darker patch
// because transparent colors are being blended together.
private func fillColor() -> CGColor {
var backgroundRed: CGFloat = 0
var backgroundGreen: CGFloat = 0
var backgroundBlue: CGFloat = 0
var tintRed: CGFloat = 0
var tintGreen: CGFloat = 0
var tintBlue: CGFloat = 0
traitCollection.performAsCurrent {
backgroundColor!.getRed(&backgroundRed, green: &backgroundGreen, blue: &backgroundBlue, alpha: nil)
tintColor.getRed(&tintRed, green: &tintGreen, blue: &tintBlue, alpha: nil)
}
let blendedRed = (backgroundRed + tintRed) / 2
let blendedGreen = (backgroundGreen + tintGreen) / 2
let blendedBlue = (backgroundBlue + tintBlue) / 2
return CGColor(red: blendedRed, green: blendedGreen, blue: blendedBlue, alpha: 1)
}
}

View File

@ -1,8 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14868" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14824"/>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
@ -17,8 +18,8 @@
<autoresizingMask key="autoresizingMask"/>
<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="vVg-1C-zJr">
<rect key="frame" x="16" y="11" width="83" height="22"/>
<fontDescription key="fontDescription" type="system" pointSize="20"/>
<rect key="frame" x="16" y="11" width="71.5" height="22"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>

View File

@ -0,0 +1,41 @@
//
// 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: HashtagHistoryView!
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

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18121" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18091"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" 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="HashtagHistoryView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="188" y="11" width="100" height="44"/>
<color key="backgroundColor" systemColor="secondarySystemGroupedBackgroundColor"/>
<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>
<resources>
<systemColor name="secondarySystemGroupedBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View File

@ -60,8 +60,8 @@ class InstanceTableViewCell: UITableViewCell {
private func updateThumbnail(url: URL) {
thumbnailImageView.image = nil
thumbnailURL = url
thumbnailRequest = ImageCache.attachments.get(url) { [weak self] (data) in
guard let self = self, self.thumbnailURL == url, let data = data, let image = UIImage(data: data) else { return }
thumbnailRequest = ImageCache.attachments.get(url) { [weak self] (_, image) in
guard let self = self, self.thumbnailURL == url, let image = image else { return }
self.thumbnailRequest = nil
DispatchQueue.main.async {
self.thumbnailImageView.image = image

View File

@ -1,59 +0,0 @@
//
// MaybeLazyStack.swift
// Tusker
//
// Created by Shadowfacts on 10/10/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct MaybeLazyVStack<Content: View>: View {
private let alignment: HorizontalAlignment
private let spacing: CGFloat?
private let content: Content
init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) {
self.alignment = alignment
self.spacing = spacing
self.content = content()
}
@ViewBuilder
var body: some View {
if #available(iOS 14.0, *) {
LazyVStack(alignment: alignment, spacing: spacing) {
content
}
} else {
VStack(alignment: alignment, spacing: spacing) {
content
}
}
}
}
struct MaybeLazyHStack<Content: View>: View {
private let alignment: VerticalAlignment
private let spacing: CGFloat?
private let content: Content
init(alignment: VerticalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) {
self.alignment = alignment
self.spacing = spacing
self.content = content()
}
@ViewBuilder
var body: some View {
if #available(iOS 14.0, *) {
LazyHStack(alignment: alignment, spacing: spacing) {
content
}
} else {
HStack(alignment: alignment, spacing: spacing) {
content
}
}
}
}

View File

@ -84,21 +84,20 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
let avatarURL = account.avatar
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (data) in
guard let self = self, let data = data, self.group.id == group.id else { return }
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self else { return }
guard let image = image,
self.group.id == group.id,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id)
imageView.image = image
}
return
}
DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id)
imageView.image = transformedImage
}
}
actionAvatarStackView.addArrangedSubview(imageView)
@ -133,21 +132,20 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
}
let avatarURL = account.avatar
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (data) in
guard let self = self, let data = data, self.group.id == groupID else { return }
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self else { return }
guard let image = image,
self.group.id == groupID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id)
imageView.image = image
}
return
}
DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id)
imageView.image = transformedImage
}
}
}

View File

@ -65,21 +65,17 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
let avatarURL = account.avatar
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (data) in
guard let self = self, let data = data, self.group.id == group.id else { return }
let image: UIImage?
if Preferences.shared.grayscaleImages {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self,
let image = image,
self.group.id == group.id,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
return
}
if let image = image {
DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id)
imageView.image = image
}
imageView.image = transformedImage
}
}
avatarStackView.addArrangedSubview(imageView)
@ -103,21 +99,20 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
}
let avatarURL = account.avatar
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (data) in
guard let self = self, let data = data, self.group.id == groupID else { return }
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self else { return }
guard let image = image,
self.group.id == groupID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id)
imageView.image = image
}
return
}
DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id)
imageView.image = transformedImage
}
}
}

View File

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18121" 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="17504.1"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18091"/>
<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"/>
@ -49,7 +48,7 @@
</label>
</subviews>
</stackView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="person.badge.plus.fill" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="7gy-KD-YT1">
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="person.fill.badge.plus" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="7gy-KD-YT1">
<rect key="frame" x="34" y="12.5" width="32" height="30"/>
<constraints>
<constraint firstAttribute="width" constant="30" id="gvV-4g-2Xr"/>
@ -76,7 +75,7 @@
</tableViewCell>
</objects>
<resources>
<image name="person.badge.plus.fill" catalog="system" width="128" height="124"/>
<image name="person.fill.badge.plus" catalog="system" width="128" height="124"/>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>

View File

@ -68,21 +68,18 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
actionLabel.setEmojis(account.emojis, identifier: account.id)
}
let avatarURL = account.avatar
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (data) in
guard let self = self, self.account == account, let data = data else { return }
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self else { return }
self.avatarRequest = nil
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
guard self.account == account,
let image = image,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
return
}
if let image = image {
DispatchQueue.main.async {
self.avatarImageView.image = image
}
self.avatarImageView.image = transformedImage
}
}
}

View File

@ -74,11 +74,9 @@ class ProfileHeaderView: UIView {
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
moreButton.addInteraction(UIPointerInteraction(delegate: self))
if #available(iOS 14.0, *) {
moreButton.showsMenuAsPrimaryAction = true
moreButton.isContextMenuInteractionEnabled = true
}
}
private func createObservers() {
cancellables = []
@ -110,9 +108,7 @@ class ProfileHeaderView: UIView {
updateImages(account: account)
if #available(iOS 14.0, *) {
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForProfile(accountID: accountID, sourceView: moreButton))
}
noteTextView.navigationDelegate = delegate
noteTextView.setTextFromHtml(account.note)
@ -191,35 +187,32 @@ class ProfileHeaderView: UIView {
let accountID = account.id
let avatarURL = account.avatar
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (data) in
guard let self = self, let data = data, self.accountID == accountID else { return }
// always load original for avatars, because ImageCache.avatars stores them scaled-down in memory
avatarRequest = ImageCache.avatars.get(avatarURL, loadOriginal: true) { [weak self] (_, image) in
guard let self = self,
let image = image,
self.accountID == accountID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
return
}
self.avatarRequest = nil
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
DispatchQueue.main.async {
self.avatarImageView.image = image
self.avatarImageView.image = transformedImage
}
}
if let header = account.header {
headerRequest = ImageCache.headers.get(header) { [weak self] (data) in
guard let self = self, let data = data, self.accountID == accountID else { return }
headerRequest = ImageCache.headers.get(header) { [weak self] (_, image) in
guard let self = self,
let image = image,
self.accountID == accountID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: header, image: image) else {
return
}
self.headerRequest = nil
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: header, data: data)
} else {
image = UIImage(data: data)
}
DispatchQueue.main.async {
self.headerImageView.image = image
self.headerImageView.image = transformedImage
}
}
}
@ -227,14 +220,6 @@ class ProfileHeaderView: UIView {
// MARK: Interaction
@IBAction func morePressed(_ sender: Any) {
guard #available(iOS 14.0, *) else {
// can't use TuskerNavigationDelegate method, because it doesn't know about the (un)follow activity
delegate?.profileHeader(self, showMoreOptionsFor: accountID, sourceView: moreButton)
return
}
}
@objc func avatarPressed() {
guard let account = mastodonController.persistentContainer.account(for: accountID) else {
return
@ -256,7 +241,6 @@ class ProfileHeaderView: UIView {
}
@available(iOS 13.4, *)
extension ProfileHeaderView: UIPointerInteractionDelegate {
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
let preview = UITargetedPreview(view: moreButton)

View File

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17154" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18121" 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="17124"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18091"/>
<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"/>
@ -23,7 +22,7 @@
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wT9-2J-uSY">
<rect key="frame" x="16" y="134" width="120" height="120"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="TkY-oK-if4">
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="TkY-oK-if4">
<rect key="frame" x="2" y="2" width="116" height="116"/>
<constraints>
<constraint firstAttribute="height" constant="116" id="eDg-Vc-o8R"/>
@ -58,9 +57,6 @@
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="image" keyPath="image" value="ellipsis" catalog="system"/>
</userDefinedRuntimeAttributes>
<connections>
<action selector="morePressed:" destination="iN0-l3-epB" eventType="touchUpInside" id="Td6-rw-Xvr"/>
</connections>
</view>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="u4P-3i-gEq">
<rect key="frame" x="16" y="262" width="398" height="600"/>

View File

@ -38,6 +38,8 @@ class BaseStatusTableViewCell: UITableViewCell {
@IBOutlet weak var favoriteButton: UIButton!
@IBOutlet weak var reblogButton: UIButton!
@IBOutlet weak var moreButton: UIButton!
private(set) var prevThreadLinkView: UIView?
private(set) var nextThreadLinkView: UIView?
var statusID: String!
var accountID: String!
@ -94,9 +96,7 @@ class BaseStatusTableViewCell: UITableViewCell {
accessibilityElements = [displayNameLabel!, contentWarningLabel!, collapseButton!, contentTextView!, attachmentsView!]
attachmentsView.isAccessibilityElement = true
if #available(iOS 14.0, *) {
moreButton.showsMenuAsPrimaryAction = true
}
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
@ -211,11 +211,9 @@ class BaseStatusTableViewCell: UITableViewCell {
reblogButton.accessibilityLabel = NSLocalizedString("Reblog", comment: "reblog button accessibility label")
}
if #available(iOS 14.0, *) {
// keep menu in sync with changed states e.g. bookmarked, muted
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForStatus(status, sourceView: moreButton))
}
}
func updateUI(account: AccountMO) {
usernameLabel.text = "@\(account.acct)"
@ -260,18 +258,14 @@ class BaseStatusTableViewCell: UITableViewCell {
let avatarURL = account.avatar
let accountID = account.id
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (data) in
guard let self = self, let data = data, self.accountID == accountID else { return }
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self,
let image = image,
self.accountID == accountID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else { return }
DispatchQueue.main.async {
self.avatarImageView.image = image
self.avatarImageView.image = transformedImage
}
}
@ -282,6 +276,52 @@ class BaseStatusTableViewCell: UITableViewCell {
displayNameLabel.updateForAccountDisplayName(account: account)
}
func setShowThreadLinks(prev: Bool, next: Bool) {
if prev {
if let prevThreadLinkView = prevThreadLinkView {
prevThreadLinkView.isHidden = false
} else {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = tintColor.withAlphaComponent(0.5)
view.layer.cornerRadius = 2.5
view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
prevThreadLinkView = view
addSubview(view)
NSLayoutConstraint.activate([
view.widthAnchor.constraint(equalToConstant: 5),
view.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor),
view.topAnchor.constraint(equalTo: topAnchor),
view.bottomAnchor.constraint(equalTo: avatarImageView.topAnchor, constant: -2),
])
}
} else {
prevThreadLinkView?.isHidden = true
}
if next {
if let nextThreadLinkView = nextThreadLinkView {
nextThreadLinkView.isHidden = false
} else {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = tintColor.withAlphaComponent(0.5)
view.layer.cornerRadius = 2.5
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
nextThreadLinkView = view
addSubview(view)
NSLayoutConstraint.activate([
view.widthAnchor.constraint(equalToConstant: 5),
view.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor),
view.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 2),
view.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
} else {
nextThreadLinkView?.isHidden = true
}
}
override func prepareForReuse() {
super.prepareForReuse()

Some files were not shown because too many files have changed in this diff Show More