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"] [submodule "Gifu"]
path = Gifu path = Gifu
url = git://github.com/kaishin/Gifu.git url = git://github.com/kaishin/Gifu.git

View File

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

View File

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

View File

@ -29,7 +29,7 @@ class AccountActivityItemSource: NSObject, UIActivityItemSource {
metadata.originalURL = account.url metadata.originalURL = account.url
metadata.url = account.url metadata.url = account.url
metadata.title = "\(account.displayName) (@\(account.username)@\(account.url.host!)" 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) { let image = UIImage(data: data) {
metadata.iconProvider = NSItemProvider(object: image) metadata.iconProvider = NSItemProvider(object: image)
} }

View File

@ -32,7 +32,7 @@ class StatusActivityItemSource: NSObject, UIActivityItemSource {
let doc = try! SwiftSoup.parse(status.content) let doc = try! SwiftSoup.parse(status.content)
let content = try! doc.text() let content = try! doc.text()
metadata.title = "\(status.account.displayName): \"\(content)\"" 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) { let image = UIImage(data: data) {
metadata.iconProvider = NSItemProvider(object: image) metadata.iconProvider = NSItemProvider(object: image)
} }

View File

@ -56,6 +56,9 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
window = UIWindow(windowScene: windowScene) window = UIWindow(windowScene: windowScene)
window!.rootViewController = nav window!.rootViewController = nav
window!.makeKeyAndVisible() window!.makeKeyAndVisible()
NotificationCenter.default.addObserver(self, selector: #selector(themePrefChanged), name: .themePreferenceChanged, object: nil)
themePrefChanged()
} }
func sceneWillResignActive(_ scene: UIScene) { func sceneWillResignActive(_ scene: UIScene) {
@ -109,4 +112,8 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
@objc private func close() { @objc private func close() {
closeWindow() 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 UIKit
import Cache
class ImageCache { 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 headers = ImageCache(name: "Headers", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60))
static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2)) static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2))
static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60)) 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 private static let disableCaching = false
#endif #endif
private let cache: Cache<Data> private let cache: ImageDataCache
private var groups = MultiThreadDictionary<URL, RequestGroup>(name: "ImageCache request groups") private var groups = MultiThreadDictionary<URL, RequestGroup>(name: "ImageCache request groups")
private var backgroundQueue = DispatchQueue(label: "ImageCache completion queue", qos: .default) private var backgroundQueue = DispatchQueue(label: "ImageCache completion queue", qos: .default)
init(name: String, memoryExpiry expiry: Expiry) { init(name: String, memoryExpiry: CacheExpiry, diskExpiry: CacheExpiry? = nil, desiredSize: CGSize? = nil) {
let storage = MemoryStorage<Data>(config: MemoryConfig(expiry: expiry)) // todo: might not always want to use UIScreen.main for this, e.g. Catalyst?
self.cache = .memory(storage) 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) { func get(_ url: URL, loadOriginal: Bool = false, completion: ((Data?, UIImage?) -> Void)?) -> Request? {
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? {
let key = url.absoluteString let key = url.absoluteString
if !ImageCache.disableCaching, if !ImageCache.disableCaching,
// todo: calling object(forKey: key) does disk I/O and this method is often called from the main thread let entry = try? cache.get(key, loadOriginal: loadOriginal) {
// in performance sensitive paths. a nice optimization to DiskStorage would be adding an internal cache if let completion = completion {
// 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) {
backgroundQueue.async { backgroundQueue.async {
completion?(data) completion(entry.data, entry.image)
}
} }
return nil return nil
} else { } else {
if let completion = completion, let group = groups[url] { if let group = groups[url] {
if let completion = completion {
return group.addCallback(completion) return group.addCallback(completion)
}
return nil
} else { } else {
let group = RequestGroup(url: url) { (data) in let group = createGroup(url: url)
if let data = data {
try? self.cache.setObject(data, forKey: key)
}
self.groups.removeValueWithoutReturning(forKey: url)
}
groups[url] = group
let request = group.addCallback(completion) let request = group.addCallback(completion)
group.run() group.run()
return request return request
@ -74,8 +58,34 @@ class ImageCache {
} }
} }
func get(_ url: URL) -> Data? { func fetchIfNotCached(_ url: URL) {
return try? cache.object(forKey: url.absoluteString) // 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) { func cancelWithoutCallback(_ url: URL) {
@ -88,11 +98,11 @@ class ImageCache {
private class RequestGroup { private class RequestGroup {
let url: URL let url: URL
private let onFinished: (Data?) -> Void private let onFinished: (Data?, UIImage?) -> Void
private var task: URLSessionDataTask? private var task: URLSessionDataTask?
private var requests = [Request]() private var requests = [Request]()
init(url: URL, onFinished: @escaping (Data?) -> Void) { init(url: URL, onFinished: @escaping (Data?, UIImage?) -> Void) {
self.url = url self.url = url
self.onFinished = onFinished self.onFinished = onFinished
} }
@ -116,7 +126,7 @@ class ImageCache {
task?.priority = max(1.0, URLSessionTask.defaultPriority + 0.1 * Float(requests.filter { !$0.cancelled }.count)) 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) let request = Request(callback: completion)
requests.append(request) requests.append(request)
updatePriority() updatePriority()
@ -141,21 +151,24 @@ class ImageCache {
} }
func complete(with data: Data?) { func complete(with data: Data?) {
let image = data != nil ? UIImage(data: data!) : nil
requests.filter { !$0.cancelled }.forEach { requests.filter { !$0.cancelled }.forEach {
if let callback = $0.callback { if let callback = $0.callback {
callback(data) callback(data, image)
} }
} }
self.onFinished(data)
self.onFinished(data, image)
} }
} }
class Request { class Request {
private weak var group: RequestGroup? private weak var group: RequestGroup?
private(set) var callback: ((Data?) -> Void)? private(set) var callback: ((Data?, UIImage?) -> Void)?
private(set) var cancelled: Bool = false private(set) var cancelled: Bool = false
init(callback: ((Data?) -> Void)?) { init(callback: ((Data?, UIImage?) -> Void)?) {
self.callback = callback 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 = UIWindow(windowScene: windowScene)
window!.rootViewController = nav window!.rootViewController = nav
window!.makeKeyAndVisible() window!.makeKeyAndVisible()
NotificationCenter.default.addObserver(self, selector: #selector(themePrefChanged), name: .themePreferenceChanged, object: nil)
themePrefChanged()
} }
func sceneWillResignActive(_ scene: UIScene) { func sceneWillResignActive(_ scene: UIScene) {
@ -58,6 +61,10 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
return scene.userActivity return scene.userActivity
} }
@objc private func themePrefChanged() {
window?.overrideUserInterfaceStyle = Preferences.shared.theme
}
} }
extension ComposeSceneDelegate: ComposeHostingControllerDelegate { extension ComposeSceneDelegate: ComposeHostingControllerDelegate {

View File

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

View File

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

View File

@ -24,6 +24,12 @@ class MastodonCachePersistentStore: NSPersistentContainer {
return context 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 statusSubject = PassthroughSubject<String, Never>()
let accountSubject = PassthroughSubject<String, Never>() let accountSubject = PassthroughSubject<String, Never>()
let relationshipSubject = 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) { func closeWindow(animation: UIWindowScene.DismissalAnimation = .standard, errorHandler: ((Error) -> Void)? = nil) {
guard let session = self.window??.windowScene?.session else { return } 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() let options = UIWindowSceneDestructionRequestOptions()
options.windowDismissalAnimation = animation options.windowDismissalAnimation = animation
UIApplication.shared.requestSceneSessionDestruction(session, options: options, errorHandler: errorHandler) UIApplication.shared.requestSceneSessionDestruction(session, options: options, errorHandler: errorHandler)

View File

@ -14,6 +14,15 @@ struct ImageGrayscalifier {
private static let context = CIContext() private static let context = CIContext()
private static let cache = NSCache<NSURL, UIImage>() 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? { static func convert(url: URL?, data: Data) -> UIImage? {
if let url = url, if let url = url,
let cached = cache.object(forKey: url as NSURL) { 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) { func activateAccount(_ account: LocalData.UserAccountInfo, animated: Bool) {
let oldMostRecentAccount = LocalData.shared.mostRecentAccountID
LocalData.shared.setMostRecentAccount(account) LocalData.shared.setMostRecentAccount(account)
window!.windowScene!.session.mastodonController = MastodonController.getForAccount(account) window!.windowScene!.session.mastodonController = MastodonController.getForAccount(account)
@ -150,7 +150,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
if let container = window?.rootViewController as? AccountSwitchingContainerViewController { if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
let direction: AccountSwitchingContainerViewController.AnimationDirection let direction: AccountSwitchingContainerViewController.AnimationDirection
if animated, 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) { let newIndex = LocalData.shared.accounts.firstIndex(of: account) {
direction = newIndex > oldIndex ? .upwards : .downwards direction = newIndex > oldIndex ? .upwards : .downwards
} else { } else {
@ -176,11 +176,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
mastodonController.getOwnAccount() mastodonController.getOwnAccount()
mastodonController.getOwnInstance() mastodonController.getOwnInstance()
if #available(iOS 14.0, *) {
return MainSplitViewController(mastodonController: mastodonController) return MainSplitViewController(mastodonController: mastodonController)
} else {
return MainTabBarViewController(mastodonController: mastodonController)
}
} }
func createOnboardingUI() -> UIViewController { func createOnboardingUI() -> UIViewController {

View File

@ -47,4 +47,12 @@ class MultiThreadDictionary<Key: Hashable, Value> {
} }
return 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() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
dragEnabled = true
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell) tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell)
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension

View File

@ -25,7 +25,7 @@ class AttachmentPreviewViewController: UIViewController {
} }
override func loadView() { 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 image = UIImage(data: data) {
let imageView: UIImageView let imageView: UIImageView
if attachment.url.pathExtension == "gif" { if attachment.url.pathExtension == "gif" {

View File

@ -44,7 +44,7 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
var animationGifData: Data? { var animationGifData: Data? {
let attachment = attachments[currentIndex] let attachment = attachments[currentIndex]
if attachment.url.pathExtension == "gif" { if attachment.url.pathExtension == "gif" {
return ImageCache.attachments.get(attachment.url) return ImageCache.attachments.getData(attachment.url)
} else { } else {
return nil 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]) { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { let ids = indexPaths.map { statuses[$0.row].id }
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { continue } prefetchStatuses(with: ids)
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments where attachment.kind == .image {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
}
}
} }
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { let ids: [String] = indexPaths.compactMap {
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { continue } guard $0.row < statuses.count else {
ImageCache.avatars.cancelWithoutCallback(status.account.avatar) return nil
for attachment in status.attachments where attachment.kind == .image {
ImageCache.attachments.cancelWithoutCallback(attachment.url)
} }
return statuses[$0.row].id
} }
cancelPrefetchingStatuses(with: ids)
} }
} }

View File

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

View File

@ -45,14 +45,7 @@ struct ComposeAttachmentsList: View {
} }
Button(action: self.addAttachment) { Button(action: self.addAttachment) {
if #available(iOS 14.0, *) {
Label("Add photo or video", systemImage: addButtonImageName) Label("Add photo or video", systemImage: addButtonImageName)
} else {
HStack {
Image(systemName: addButtonImageName)
Text("Add photo or video")
}
}
} }
.disabled(!canAddAttachment) .disabled(!canAddAttachment)
.foregroundColor(.blue) .foregroundColor(.blue)
@ -61,14 +54,7 @@ struct ComposeAttachmentsList: View {
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2)) .listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
Button(action: self.createDrawing) { Button(action: self.createDrawing) {
if #available(iOS 14.0, *) {
Label("Draw something", systemImage: "hand.draw") Label("Draw something", systemImage: "hand.draw")
} else {
HStack(alignment: .lastTextBaseline) {
Image(systemName: "hand.draw")
Text("Draw something")
}
}
} }
.disabled(!canAddAttachment) .disabled(!canAddAttachment)
.foregroundColor(.blue) .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 { struct ComposeAutocompleteMentionsView: View {
@EnvironmentObject private var mastodonController: MastodonController @EnvironmentObject private var mastodonController: MastodonController
@EnvironmentObject private var uiState: ComposeUIState @EnvironmentObject private var uiState: ComposeUIState
@ -104,7 +91,6 @@ struct ComposeAutocompleteMentionsView: View {
Spacer() Spacer()
} }
.padding(.horizontal, 8) .padding(.horizontal, 8)
.iOS13OnlyPadding()
} }
.onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged) .onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged)
.onDisappear { .onDisappear {
@ -333,7 +319,6 @@ struct ComposeAutocompleteHashtagsView: View {
Spacer() Spacer()
} }
.padding(.horizontal, 8) .padding(.horizontal, 8)
.iOS13OnlyPadding()
} }
.onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged) .onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged)
.onDisappear { .onDisappear {

View File

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

View File

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

View File

@ -27,7 +27,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
private var cancellables = [AnyCancellable]() private var cancellables = [AnyCancellable]()
private var keyboardHeight: CGFloat = 0
private var toolbarHeight: CGFloat = 44 private var toolbarHeight: CGFloat = 44
private var mainToolbar: UIToolbar! private var mainToolbar: UIToolbar!
@ -115,13 +114,7 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
toolbar.translatesAutoresizingMaskIntoConstraints = false toolbar.translatesAutoresizingMaskIntoConstraints = false
toolbar.isAccessibilityElement = true toolbar.isAccessibilityElement = true
let visibilityAction: Selector? let visibilityItem = UIBarButtonItem(image: UIImage(systemName: draft.visibility.imageName), style: .plain, target: nil, action: nil)
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)
visibilityBarButtonItems.append(visibilityItem) visibilityBarButtonItems.append(visibilityItem)
visibilityChanged(draft.visibility) visibilityChanged(draft.visibility)
@ -135,7 +128,7 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
} }
private func updateAdditionalSafeAreaInsets() { 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) { @objc private func keyboardWillShow(_ notification: Foundation.Notification) {
@ -147,19 +140,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
accessoryView.alpha = 1 accessoryView.alpha = 1
accessoryView.isHidden = false 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) { @objc private func keyboardWillHide(_ notification: Foundation.Notification) {
@ -192,13 +172,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
} completion: { (finished) in } completion: { (finished) in
accessoryView.alpha = 1 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) { @objc private func keyboardDidHide(_ notification: Foundation.Notification) {
@ -214,7 +187,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
item.image = UIImage(systemName: newVisibility.imageName) item.image = UIImage(systemName: newVisibility.imageName)
item.image!.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName) 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) 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 elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
let state = visibility == newVisibility ? UIMenuElement.State.on : .off 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 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) item.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements)
} }
} }
}
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false } guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false }
@ -255,18 +226,6 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
draft.contentWarningEnabled = !draft.contentWarningEnabled 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() { @objc func draftsButtonPresed() {
let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!, exclude: draft) let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!, exclude: draft)
draftsVC.delegate = self draftsVC.delegate = self

View File

@ -44,15 +44,10 @@ struct ComposeView: View {
} }
var body: some 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 { mostOfTheBody.toolbar {
ToolbarItem(placement: .cancellationAction) { cancelButton } ToolbarItem(placement: .cancellationAction) { cancelButton }
ToolbarItem(placement: .confirmationAction) { postButton } ToolbarItem(placement: .confirmationAction) { postButton }
} }
} else {
mostOfTheBody.navigationBarItems(leading: cancelButton, trailing: postButton)
}
} }
var mostOfTheBody: some View { var mostOfTheBody: some View {
@ -82,8 +77,6 @@ struct ComposeView: View {
@ViewBuilder @ViewBuilder
var autocompleteSuggestions: some View { 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) { VStack(spacing: 0) {
Spacer() Spacer()
if let state = uiState.autocompleteState { if let state = uiState.autocompleteState {
@ -92,14 +85,6 @@ struct ComposeView: View {
} }
.transition(.move(edge: .bottom)) .transition(.move(edge: .bottom))
.animation(.default) .animation(.default)
} else {
VStack(spacing: 0) {
Spacer()
if let state = uiState.autocompleteState {
ComposeAutocompleteView(autocompleteState: state)
}
}
}
} }
func mainStack(outerMinY: CGFloat) -> some View { func mainStack(outerMinY: CGFloat) -> some View {

View File

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

View File

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

View File

@ -7,25 +7,31 @@
// //
import UIKit import UIKit
import SafariServices
import Pachyderm import Pachyderm
import CoreData
class ConversationNode {
let status: StatusMO
var children: [ConversationNode]
init(status: StatusMO) {
self.status = status
self.children = []
}
}
class ConversationTableViewController: EnhancedTableViewController { class ConversationTableViewController: EnhancedTableViewController {
static let showPostsImage = UIImage(systemName: "eye.fill")! static let showPostsImage = UIImage(systemName: "eye.fill")!
static let hidePostsImage = UIImage(systemName: "eye.slash.fill")! static let hidePostsImage = UIImage(systemName: "eye.slash.fill")!
static let bottomSeparatorTag = 101
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
let mainStatusID: String let mainStatusID: String
let mainStatusState: StatusState let mainStatusState: StatusState
var statuses: [(id: String, state: StatusState)] = [] { private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
didSet {
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
}
var showStatusesAutomatically = false var showStatusesAutomatically = false
var visibilityBarButtonItem: UIBarButtonItem! var visibilityBarButtonItem: UIBarButtonItem!
@ -46,7 +52,8 @@ class ConversationTableViewController: EnhancedTableViewController {
deinit { deinit {
guard let persistentContainer = mastodonController?.persistentContainer else { return } 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() 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: "TimelineStatusTableViewCell", bundle: nil), forCellReuseIdentifier: "statusCell")
tableView.register(UINib(nibName: "ConversationMainStatusTableViewCell", bundle: nil), forCellReuseIdentifier: "mainStatusCell") tableView.register(UINib(nibName: "ConversationMainStatusTableViewCell", bundle: nil), forCellReuseIdentifier: "mainStatusCell")
tableView.register(UINib(nibName: "ExpandThreadTableViewCell", bundle: .main), forCellReuseIdentifier: "expandThreadCell")
tableView.prefetchDataSource = self 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 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 { guard let mainStatus = self.mastodonController.persistentContainer.status(for: self.mainStatusID) else {
fatalError("Missing cached status \(self.mainStatusID)") fatalError("Missing cached status \(self.mainStatusID)")
@ -81,19 +151,39 @@ class ConversationTableViewController: EnhancedTableViewController {
let parents = self.getDirectParents(inReplyTo: mainStatusInReplyToID, from: context.ancestors) let parents = self.getDirectParents(inReplyTo: mainStatusInReplyToID, from: context.ancestors)
let parentStatuses = context.ancestors.filter { parents.contains($0.id) } let parentStatuses = context.ancestors.filter { parents.contains($0.id) }
self.mastodonController.persistentContainer.addAll(statuses: parentStatuses) {
self.mastodonController.persistentContainer.addAll(statuses: context.descendants) { // todo: should this really be blindly adding all the descendants?
self.statuses = parents.map { ($0, .unknown) } + self.statuses + context.descendants.map { ($0.id, .unknown) } self.mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants) {
let indexPath = IndexPath(row: parents.count, section: 0)
DispatchQueue.main.async { 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) 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 statuses = statuses
var parents = [String]() var parents = [String]()
@ -108,38 +198,99 @@ class ConversationTableViewController: EnhancedTableViewController {
return parents 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 { func removeAllInReplyTo(id: String) -> [StatusMO] {
return 1 let statuses = descendants.filter { $0.inReplyToID == id }
descendants.removeAll { $0.inReplyToID == id }
return statuses
} }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { var nodes: [String: ConversationNode] = [
return statuses.count 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 { return nodes[mainStatusID]!.children
let (id, state) = statuses[indexPath.row] }
if id == mainStatusID { private func addFlattenedChildThreadsToSnapshot(_ childThreads: [ConversationNode], mainStatus: StatusMO, snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "mainStatusCell", for: indexPath) as? ConversationMainStatusTableViewCell else { fatalError() } var childThreads = childThreads
cell.selectionStyle = .none
cell.showStatusAutomatically = showStatusesAutomatically // child threads by the same author as the main status come first
cell.delegate = self let pivotIndex = childThreads.partition(by: { $0.status.account.id != mainStatus.account.id })
cell.updateUI(statusID: id, state: state)
return cell // 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 { } else {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as? TimelineStatusTableViewCell else { fatalError() } let sameAuthorStatuses = currentNode.children.filter({ $0.status.account.id == node.status.account.id })
cell.showStatusAutomatically = showStatusesAutomatically if sameAuthorStatuses.count == 1 {
cell.showReplyIndicator = false next = sameAuthorStatuses[0]
cell.delegate = self } else {
cell.updateUI(statusID: id, state: state) snapshot.appendItems([.expandThread(childThreads: currentNode.children)])
return cell 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 // 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 { override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true return true
} }
@ -155,7 +306,8 @@ class ConversationTableViewController: EnhancedTableViewController {
@objc func toggleVisibilityButtonPressed() { @objc func toggleVisibilityButtonPressed() {
showStatusesAutomatically = !showStatusesAutomatically 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 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 { extension ConversationTableViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController { mastodonController }
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { 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]) { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { let ids: [String] = indexPaths.compactMap { item(for: $0)?.id }
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { continue } prefetchStatuses(with: ids)
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments where attachment.kind == .image {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
}
}
} }
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { let ids: [String] = indexPaths.compactMap { item(for: $0)?.id }
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else { continue } cancelPrefetchingStatuses(with: ids)
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments where attachment.kind == .image {
ImageCache.attachments.cancelWithoutCallback(attachment.url)
}
}
} }
} }

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 UIKit
import Pachyderm import Pachyderm
class AddSavedHashtagViewController: SearchResultsViewController { class AddSavedHashtagViewController: EnhancedTableViewController {
weak var mastodonController: MastodonController!
var resultsController: SearchResultsViewController!
var searchController: UISearchController! var searchController: UISearchController!
var dataSource: UITableViewDiffableDataSource<Section, Item>!
init(mastodonController: MastodonController) { init(mastodonController: MastodonController) {
super.init(mastodonController: mastodonController, resultTypes: [.hashtags]) self.mastodonController = mastodonController
super.init(style: .grouped)
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -24,14 +31,32 @@ class AddSavedHashtagViewController: SearchResultsViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
delegate = self title = NSLocalizedString("Search", comment: "search screen title")
searchController = UISearchController(searchResultsController: nil) tableView.register(UINib(nibName: "TrendingHashtagTableViewCell", bundle: .main), forCellReuseIdentifier: "trendingTagCell")
searchController.obscuresBackgroundDuringPresentation = false 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.hidesNavigationBarDuringPresentation = false
searchController.searchResultsUpdater = resultsController
searchController.searchBar.autocapitalizationType = .none searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.placeholder = NSLocalizedString("Search for hashtags to save", comment: "add saved hashtag search field placeholder") 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 definesPresentationContext = true
@ -41,11 +66,38 @@ class AddSavedHashtagViewController: SearchResultsViewController {
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed)) navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed))
} }
override func performSearch(query: String?) { override func viewWillAppear(_ animated: Bool) {
if let query = query, !query.starts(with: "#") { super.viewWillAppear(animated)
super.performSearch(query: "#\(query)")
} else { let request = Client.getTrends(limit: 10)
super.performSearch(query: query) 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: SearchResultsViewControllerDelegate { extension AddSavedHashtagViewController {
func selectedSearchResult(hashtag: Hashtag) { enum Section {
SavedDataManager.shared.add(hashtag: hashtag, for: mastodonController.accountInfo!) case trendingTags
dismiss(animated: true) }
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) {
selectHashtag(hashtag)
} }
} }

View File

@ -10,23 +10,22 @@ import UIKit
import Combine import Combine
import Pachyderm import Pachyderm
class ExploreViewController: EnhancedTableViewController { class ExploreViewController: UIViewController, UICollectionViewDelegate {
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
var dataSource: DataSource! private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var resultsController: SearchResultsViewController! private(set) var resultsController: SearchResultsViewController!
var searchController: UISearchController! private(set) var searchController: UISearchController!
var searchControllerStatusOnAppearance: Bool? = nil var searchControllerStatusOnAppearance: Bool? = nil
init(mastodonController: MastodonController) { init(mastodonController: MastodonController) {
self.mastodonController = mastodonController self.mastodonController = mastodonController
super.init(style: .insetGrouped) super.init(nibName: nil, bundle: nil)
dragEnabled = true
title = NSLocalizedString("Explore", comment: "explore tab title") title = NSLocalizedString("Explore", comment: "explore tab title")
tabBarItem.image = UIImage(systemName: "magnifyingglass") tabBarItem.image = UIImage(systemName: "magnifyingglass")
@ -39,63 +38,23 @@ class ExploreViewController: EnhancedTableViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.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 dataSource = createDataSource()
let cell = tableView.dequeueReusableCell(withIdentifier: "basicCell", for: indexPath) applyInitialSnapshot()
switch item { if mastodonController.instance == nil {
case .bookmarks: mastodonController.getOwnInstance(completion: self.ownInstanceLoaded(_:))
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
} }
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 = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController! resultsController.exploreNavigationController = self.navigationController!
searchController = UISearchController(searchResultsController: resultsController) 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(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil)
}
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) { 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() let request = Client.getLists()
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
guard case let .success(lists, _) = response else { guard case let .success(lists, _) = response else {
fatalError() return
} }
var snapshot = self.dataSource.snapshot() var snapshot = self.dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .lists)) 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 { DispatchQueue.main.async {
self.dataSource.apply(snapshot) self.dataSource.apply(snapshot)
@ -142,26 +179,31 @@ class ExploreViewController: EnhancedTableViewController {
} }
} }
@objc func savedHashtagsChanged() { @objc private func savedHashtagsChanged() {
let account = mastodonController.accountInfo! let account = mastodonController.accountInfo!
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags)) 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) dataSource.apply(snapshot)
} }
@objc func savedInstancesChanged() { @objc private func savedInstancesChanged() {
let account = mastodonController.accountInfo! let account = mastodonController.accountInfo!
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedInstances)) 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) dataSource.apply(snapshot)
} }
func deleteList(_ list: List) { private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) {
let title = String(format: NSLocalizedString("Are you sure want to delete the '%@' list?", comment: "delete list alert title"), list.title) 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) 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 alert.addAction(UIAlertAction(title: NSLocalizedString("Delete List", comment: "delete list alert confirm button"), style: .destructive, handler: { (_) in
let request = List.delete(list) let request = List.delete(list)
@ -174,6 +216,7 @@ class ExploreViewController: EnhancedTableViewController {
snapshot.deleteItems([.list(list)]) snapshot.deleteItems([.list(list)])
DispatchQueue.main.async { DispatchQueue.main.async {
self.dataSource.apply(snapshot) self.dataSource.apply(snapshot)
completion(true)
} }
} }
})) }))
@ -190,9 +233,38 @@ class ExploreViewController: EnhancedTableViewController {
SavedDataManager.shared.remove(instance: instanceURL, for: account) 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) { switch dataSource.itemIdentifier(for: indexPath) {
case nil: case nil:
return return
@ -200,11 +272,17 @@ class ExploreViewController: EnhancedTableViewController {
case .bookmarks: case .bookmarks:
show(BookmarksTableViewController(mastodonController: mastodonController), sender: nil) 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): case let .list(list):
show(ListTimelineViewController(for: list, mastodonController: mastodonController), sender: nil) show(ListTimelineViewController(for: list, mastodonController: mastodonController), sender: nil)
case .addList: 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) 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.addTextField(configurationHandler: nil)
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "new list alert cancel button"), style: .cancel, handler: 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) show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
case .addSavedHashtag: case .addSavedHashtag:
tableView.selectRow(at: nil, animated: true, scrollPosition: .none) collectionView.deselectItem(at: indexPath, animated: true)
let navController = UINavigationController(rootViewController: AddSavedHashtagViewController(mastodonController: mastodonController)) let navController = UINavigationController(rootViewController: AddSavedHashtagViewController(mastodonController: mastodonController))
present(navController, animated: true) present(navController, animated: true)
@ -240,7 +318,7 @@ class ExploreViewController: EnhancedTableViewController {
show(InstanceTimelineViewController(for: url, parentMastodonController: mastodonController), sender: nil) show(InstanceTimelineViewController(for: url, parentMastodonController: mastodonController), sender: nil)
case .findInstance: case .findInstance:
tableView.selectRow(at: nil, animated: true, scrollPosition: .none) collectionView.deselectItem(at: indexPath, animated: true)
let findController = FindInstanceViewController(parentMastodonController: mastodonController) let findController = FindInstanceViewController(parentMastodonController: mastodonController)
findController.instanceTimelineDelegate = self findController.instanceTimelineDelegate = self
let navController = UINavigationController(rootViewController: findController) 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 { extension ExploreViewController {
enum Section: CaseIterable { enum Section: CaseIterable {
case bookmarks case bookmarks
case discover
case lists case lists
case savedHashtags case savedHashtags
case savedInstances 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 { enum Item: Hashable {
case bookmarks case bookmarks
case trendingTags
case profileDirectory
case list(List) case list(List)
case addList case addList
case savedHashtag(Hashtag) case savedHashtag(Hashtag)
@ -297,10 +363,60 @@ extension ExploreViewController {
case savedInstance(URL) case savedInstance(URL)
case findInstance 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) { switch (lhs, rhs) {
case (.bookmarks, .bookmarks): case (.bookmarks, .bookmarks):
return true return true
case (.trendingTags, .trendingTags):
return true
case (.profileDirectory, .profileDirectory):
return true
case let (.list(a), .list(b)): case let (.list(a), .list(b)):
return a.id == b.id return a.id == b.id
case (.addList, .addList): case (.addList, .addList):
@ -317,10 +433,15 @@ extension ExploreViewController {
return false return false
} }
} }
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
switch self { switch self {
case .bookmarks: case .bookmarks:
hasher.combine("bookmarks") hasher.combine("bookmarks")
case .trendingTags:
hasher.combine("trendingTags")
case .profileDirectory:
hasher.combine("profileDirectory")
case let .list(list): case let .list(list):
hasher.combine("list") hasher.combine("list")
hasher.combine(list.id) 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 { extension ExploreViewController: InstanceTimelineViewControllerDelegate {
@ -404,12 +474,13 @@ extension ExploreViewController: InstanceTimelineViewControllerDelegate {
} }
} }
extension ExploreViewController { extension ExploreViewController: UICollectionViewDragDelegate {
override func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let item = dataSource.itemIdentifier(for: indexPath), guard let item = dataSource.itemIdentifier(for: indexPath),
let accountID = mastodonController.accountInfo?.id else { let accountID = mastodonController.accountInfo?.id else {
return [] return []
} }
let provider: NSItemProvider let provider: NSItemProvider
switch item { switch item {
case .bookmarks: case .bookmarks:
@ -425,11 +496,7 @@ extension ExploreViewController {
case let .savedInstance(url): case let .savedInstance(url):
provider = NSItemProvider(object: url as NSURL) provider = NSItemProvider(object: url as NSURL)
// todo: should dragging public timelines into new windows be supported? // todo: should dragging public timelines into new windows be supported?
case .addList: case .trendingTags, .profileDirectory, .addList, .addSavedHashtag, .findInstance:
return []
case .addSavedHashtag:
return []
case .findInstance:
return [] return []
} }
return [UIDragItem(itemProvider: provider)] 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 view.isHidden = false
if UIAccessibility.prefersCrossFadeTransitionsBackwardsCompat { if UIAccessibility.prefersCrossFadeTransitions {
view.alpha = 0 view.alpha = 0
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) { UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut, .allowUserInteraction]) {
self.view.alpha = 1 self.view.alpha = 1

View File

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

View File

@ -85,19 +85,20 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
overrideUserInterfaceStyle = .dark overrideUserInterfaceStyle = .dark
view.backgroundColor = .black view.backgroundColor = .black
if let data = cache.get(url) { // always load full resolution from disk for large image, in case the cache is scaled
createLargeImage(data: data, url: url) if let entry = cache.get(url, loadOriginal: true) {
createLargeImage(data: entry.data, image: entry.image, url: url)
} else { } else {
createPreview() createPreview()
loadingVC = LoadingViewController() loadingVC = LoadingViewController()
embedChild(loadingVC!) 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 } guard let self = self else { return }
self.imageRequest = nil self.imageRequest = nil
DispatchQueue.main.async { DispatchQueue.main.async {
self.loadingVC?.removeViewAndController() 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 } guard !loaded else { return }
loaded = true loaded = true
let image: UIImage? if let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) {
if Preferences.shared.grayscaleImages {
image = ImageGrayscalifier.convert(url: url, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
let gifData = url.pathExtension == "gif" ? data : nil 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 { class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
if UIAccessibility.prefersCrossFadeTransitionsBackwardsCompat { if UIAccessibility.prefersCrossFadeTransitions {
return 0.2 return 0.2
} else { } else {
return 0.4 return 0.4
@ -51,7 +51,7 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
return return
} }
if UIAccessibility.prefersCrossFadeTransitionsBackwardsCompat { if UIAccessibility.prefersCrossFadeTransitions {
animateCrossFadeTransition(using: transitionContext) animateCrossFadeTransition(using: transitionContext)
return return
} }

View File

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

View File

@ -47,6 +47,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
guard case let .account(id) = item else { fatalError() } guard case let .account(id) = item else { fatalError() }
let cell = tableView.dequeueReusableCell(withIdentifier: "accountCell", for: indexPath) as! AccountTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: "accountCell", for: indexPath) as! AccountTableViewCell
cell.delegate = self
cell.updateUI(accountID: id) cell.updateUI(accountID: id)
return cell 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) embedChild(newRoot)
if direction != .none { if direction != .none {
if UIAccessibility.prefersCrossFadeTransitionsBackwardsCompat { if UIAccessibility.prefersCrossFadeTransitions {
newRoot.view.alpha = 0 newRoot.view.alpha = 0
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseInOut) { UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseInOut) {

View File

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

View File

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

View File

@ -98,7 +98,9 @@ class NotificationsTableViewController: TimelineLikeTableViewController<Notifica
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
guard case let .success(newNotifications, pagination) = response else { fatalError() } 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) let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
@ -211,7 +213,7 @@ extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { for indexPath in indexPaths {
for notification in item(for: indexPath).notifications { 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 automationSection
cachingSection cachingSection
} }
.insetOrGroupedListStyle() .listStyle(InsetGroupedListStyle())
.navigationBarTitle(Text("Advanced")) .navigationBarTitle(Text("Advanced"))
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -89,7 +89,7 @@ struct PreferencesView: View {
} }
} }
} }
.insetOrGroupedListStyle() .listStyle(InsetGroupedListStyle())
.navigationBarTitle(Text("Preferences"), displayMode: .inline) .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 #if DEBUG
struct PreferencesView_Previews : PreviewProvider { struct PreferencesView_Previews : PreviewProvider {
static var previews: some View { static var previews: some View {

View File

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

View File

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

View File

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

View File

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

View File

@ -69,13 +69,11 @@ class ProfileViewController: UIPageViewController {
view.backgroundColor = .systemBackground view.backgroundColor = .systemBackground
let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning)) 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: [ 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 UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
self.composeDirectMentioning() self.composeDirectMentioning()
}) })
]) ])
}
navigationItem.rightBarButtonItem = composeButton navigationItem.rightBarButtonItem = composeButton
headerView = ProfileHeaderView.create() headerView = ProfileHeaderView.create()

View File

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

View File

@ -113,7 +113,11 @@ class TimelineTableViewController: TimelineLikeTableViewController<TimelineEntry
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
guard case let .success(statuses, pagination) = response else { fatalError() } 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) { self.mastodonController?.persistentContainer.addAll(statuses: statuses) {
completion(statuses.map { ($0.id, .unknown) }) 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]) { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { let ids = indexPaths.map { item(for: $0).id }
guard let status = mastodonController.persistentContainer.status(for: item(for: indexPath).id) else { prefetchStatuses(with: ids)
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)
}
}
} }
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths { let ids: [String] = indexPaths.compactMap {
// todo: this means when removing cells, we can't cancel prefetching guard $0.section < sections.count,
// is this an issue? $0.row < sections[$0.section].count else {
guard indexPath.section < sections.count, return nil
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)
} }
return item(for: $0).id
} }
cancelPrefetchingStatuses(with: ids)
} }
} }

View File

@ -40,7 +40,7 @@ extension MenuPreviewProvider {
guard mastodonController.loggedIn else { guard mastodonController.loggedIn else {
return [ return [
openInSafariAction(url: account.url), 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 } guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView) self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView)
}) })
@ -54,14 +54,14 @@ extension MenuPreviewProvider {
}), }),
] ]
if accountID != mastodonController.account.id, if accountID != mastodonController.account.id {
#available(iOS 14.0, *) {
actionsSection.append(UIDeferredMenuElement({ (elementHandler) in actionsSection.append(UIDeferredMenuElement({ (elementHandler) in
guard let mastodonController = self.mastodonController else { guard let mastodonController = self.mastodonController else {
elementHandler([]) elementHandler([])
return return
} }
let request = Client.getRelationships(accounts: [account.id]) let request = Client.getRelationships(accounts: [account.id])
// talk about callback hell :/
mastodonController.run(request) { [weak self] (response) in mastodonController.run(request) { [weak self] (response) in
if let self = self, if let self = self,
case let .success(results, _) = response, case let .success(results, _) = response,
@ -89,7 +89,7 @@ extension MenuPreviewProvider {
let shareSection = [ let shareSection = [
openInSafariAction(url: account.url), 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 } guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView) self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView)
}) })
@ -104,7 +104,7 @@ extension MenuPreviewProvider {
func actionsForURL(_ url: URL, sourceView: UIView?) -> [UIAction] { func actionsForURL(_ url: URL, sourceView: UIView?) -> [UIAction] {
return [ return [
openInSafariAction(url: url), 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 } guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forURL: url, sourceView: sourceView) self.navigationDelegate?.showMoreOptions(forURL: url, sourceView: sourceView)
}) })
@ -139,7 +139,7 @@ extension MenuPreviewProvider {
guard mastodonController.loggedIn else { guard mastodonController.loggedIn else {
return [ return [
openInSafariAction(url: status.url!), 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 } guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView) self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView)
}) })
@ -190,7 +190,7 @@ extension MenuPreviewProvider {
var shareSection = [ var shareSection = [
openInSafariAction(url: status.url!), 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 } guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView) self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView)
}), }),
@ -223,8 +223,8 @@ extension MenuPreviewProvider {
} }
private func openInSafariAction(url: URL) -> UIAction { private func openInSafariAction(url: URL) -> UIAction {
return createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { (_) in return createAction(identifier: "openinsafari", title: "Open in Safari", systemImageName: "safari", handler: { [weak self] (_) in
self.navigationDelegate?.selected(url: url, allowUniversalLinks: false) 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() { private func pruneOffscreenRows() {
guard let lastVisibleRow = lastLastVisibleRow else { guard let lastVisibleRow = lastLastVisibleRow,
// never remove the last section
sections.count - headerSectionsCount() > 1 else {
return return
} }
let lastSectionIndex = sections.count - 1 let lastSectionIndex = sections.count - 1
@ -134,11 +136,11 @@ class TimelineLikeTableViewController<Item>: EnhancedTableViewController, Refres
} }
willRemoveRows(at: indexPathsToRemove) willRemoveRows(at: indexPathsToRemove)
sections.removeSubrange(sectionsToRemove)
UIView.performWithoutAnimation { UIView.performWithoutAnimation {
tableView.deleteSections(IndexSet(sectionsToRemove), with: .none) tableView.deleteSections(IndexSet(sectionsToRemove), with: .none)
} }
sections.removeSubrange(sectionsToRemove)
} else if lastVisibleRow.section == lastSectionIndex { } else if lastVisibleRow.section == lastSectionIndex {
let lastSection = sections.last! let lastSection = sections.last!
let lastRowIndex = lastSection.count - 1 let lastRowIndex = lastSection.count - 1
@ -146,7 +148,7 @@ class TimelineLikeTableViewController<Item>: EnhancedTableViewController, Refres
if lastVisibleRow.row < lastRowIndex - pageSize { if lastVisibleRow.row < lastRowIndex - pageSize {
// if there are more than pageSize rows in the current section below the last visible one // 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 { let indexPathsToRemove = rowIndicesInLastSectionToRemove.map {
IndexPath(row: $0, section: lastSectionIndex) IndexPath(row: $0, section: lastSectionIndex)

View File

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

View File

@ -12,6 +12,8 @@ import Pachyderm
protocol TuskerNavigationDelegate: UIViewController { protocol TuskerNavigationDelegate: UIViewController {
var apiController: MastodonController { get } var apiController: MastodonController { get }
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController
} }
extension TuskerNavigationDelegate { 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) { func selected(status statusID: String) {
self.selected(status: statusID, state: .unknown) self.selected(status: statusID, state: .unknown)
} }
func selected(status statusID: String, state: StatusState) { func selected(status statusID: String, state: StatusState) {
// todo: is this necessary? should the conversation main status cell prevent this show(conversation(mainStatusID: statusID, state: state), sender: self)
// 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)
} }
func compose(editing draft: Draft) { 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 status = apiController.persistentContainer.status(for: statusID) else { fatalError("Missing cached status \(statusID)") }
guard let url = status.url else { fatalError("Missing url for 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) 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 { private func moreOptions(forAccount accountID: String) -> UIActivityViewController {
guard let account = apiController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID)") } 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) 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?) { func showMoreOptions(forStatus statusID: String, sourceView: UIView?) {

View File

@ -2,9 +2,17 @@
/// From https://github.com/woltapp/blurhash/blob/b23214ddcab803fe1ec9a3e6b20558caf33a23a5/Swift/BlurHashDecode.swift /// From https://github.com/woltapp/blurhash/blob/b23214ddcab803fe1ec9a3e6b20558caf33a23a5/Swift/BlurHashDecode.swift
import UIKit 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 { extension UIImage {
public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) { 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 sizeFlag = String(blurHash[0]).decode83()
let numY = (sizeFlag / 9) + 1 let numY = (sizeFlag / 9) + 1

View File

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

View File

@ -69,11 +69,11 @@ class LargeAccountDetailView: UIView {
displayNameLabel.updateForAccountDisplayName(account: account) displayNameLabel.updateForAccountDisplayName(account: account)
usernameLabel.text = "@\(account.acct)" usernameLabel.text = "@\(account.acct)"
avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (_, image) in
guard let self = self, let data = data else { return } guard let self = self, let image = image else { return }
self.avatarRequest = nil self.avatarRequest = nil
DispatchQueue.main.async { 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 { var body: some View {
if #available(iOS 14.0, *) {
text text
.font(.system(size: CGFloat(fontSize), weight: .semibold)) .font(.system(size: CGFloat(fontSize), weight: .semibold))
.onAppear(perform: self.loadEmojis) .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() { private func loadEmojis() {
let fullRange = NSRange(account.displayName.startIndex..., in: account.displayName) let fullRange = NSRange(account.displayName.startIndex..., in: account.displayName)
let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange) let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange)
@ -54,9 +47,9 @@ struct AccountDisplayNameLabel<Account: AccountProtocol>: View {
} }
group.enter() group.enter()
let request = ImageCache.emojis.get(emoji.url) { (data) in let request = ImageCache.emojis.get(emoji.url) { (_, image) in
defer { group.leave() } 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 size = CGSize(width: fontSize, height: fontSize)
let renderer = UIGraphicsImageRenderer(size: size) 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() { func loadImage() {
let attachmentURL = attachment.url 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 } guard let self = self, let data = data else { return }
self.attachmentRequest = nil self.attachmentRequest = nil
if self.attachment.url.pathExtension == "gif" { if self.attachment.url.pathExtension == "gif" {

View File

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

View File

@ -63,20 +63,13 @@ class ContentTextView: LinkTextView {
for emoji in emojis { for emoji in emojis {
group.enter() group.enter()
_ = ImageCache.emojis.get(emoji.url) { (data) in _ = ImageCache.emojis.get(emoji.url) { (_, image) in
defer { group.leave() } defer { group.leave() }
guard let data = data else { guard let image = image,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: emoji.url, image: image) else {
return return
} }
let image: UIImage? emojiImages[emoji.shortcode] = transformedImage
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
}
} }
} }

View File

@ -33,16 +33,12 @@ struct CustomEmojiImageView: View {
} }
private func loadImage() { private func loadImage() {
request = ImageCache.emojis.get(emoji.url) { (data) in request = ImageCache.emojis.get(emoji.url) { (_, image) in
if let data = data, let image = UIImage(data: data) {
DispatchQueue.main.async { DispatchQueue.main.async {
self.request = nil self.request = nil
if let image = image {
self.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"?> <?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"/> <device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies> <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="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
@ -17,8 +18,8 @@
<autoresizingMask key="autoresizingMask"/> <autoresizingMask key="autoresizingMask"/>
<subviews> <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"> <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"/> <rect key="frame" x="16" y="11" width="71.5" height="22"/>
<fontDescription key="fontDescription" type="system" pointSize="20"/> <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/> <nil key="textColor"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
</label> </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) { private func updateThumbnail(url: URL) {
thumbnailImageView.image = nil thumbnailImageView.image = nil
thumbnailURL = url thumbnailURL = url
thumbnailRequest = ImageCache.attachments.get(url) { [weak self] (data) in thumbnailRequest = ImageCache.attachments.get(url) { [weak self] (_, image) in
guard let self = self, self.thumbnailURL == url, let data = data, let image = UIImage(data: data) else { return } guard let self = self, self.thumbnailURL == url, let image = image else { return }
self.thumbnailRequest = nil self.thumbnailRequest = nil
DispatchQueue.main.async { DispatchQueue.main.async {
self.thumbnailImageView.image = image 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.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
let avatarURL = account.avatar let avatarURL = account.avatar
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (data) in avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self, let data = data, self.group.id == group.id else { return } guard let self = self else { return }
guard let image = image,
let image: UIImage? self.group.id == group.id,
if self.isGrayscale { let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id) 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) actionAvatarStackView.addArrangedSubview(imageView)
@ -133,21 +132,20 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
} }
let avatarURL = account.avatar let avatarURL = account.avatar
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (data) in avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self, let data = data, self.group.id == groupID else { return } guard let self = self else { return }
guard let image = image,
let image: UIImage? self.group.id == groupID,
if self.isGrayscale { let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id) 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.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
let avatarURL = account.avatar let avatarURL = account.avatar
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (data) in avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self, let data = data, self.group.id == group.id else { return } guard let self = self,
let image = image,
let image: UIImage? self.group.id == group.id,
if Preferences.shared.grayscaleImages { let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
image = ImageGrayscalifier.convert(url: avatarURL, data: data) return
} else {
image = UIImage(data: data)
} }
if let image = image {
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id) self.avatarRequests.removeValue(forKey: account.id)
imageView.image = image imageView.image = transformedImage
}
} }
} }
avatarStackView.addArrangedSubview(imageView) avatarStackView.addArrangedSubview(imageView)
@ -103,21 +99,20 @@ class FollowNotificationGroupTableViewCell: UITableViewCell {
} }
let avatarURL = account.avatar let avatarURL = account.avatar
avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (data) in avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self, let data = data, self.group.id == groupID else { return } guard let self = self else { return }
guard let image = image,
let image: UIImage? self.group.id == groupID,
if self.isGrayscale { let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
if let image = image {
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarRequests.removeValue(forKey: account.id) 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"?> <?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"/> <device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18091"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -49,7 +48,7 @@
</label> </label>
</subviews> </subviews>
</stackView> </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"/> <rect key="frame" x="34" y="12.5" width="32" height="30"/>
<constraints> <constraints>
<constraint firstAttribute="width" constant="30" id="gvV-4g-2Xr"/> <constraint firstAttribute="width" constant="30" id="gvV-4g-2Xr"/>
@ -76,7 +75,7 @@
</tableViewCell> </tableViewCell>
</objects> </objects>
<resources> <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"> <systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/> <color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor> </systemColor>

View File

@ -68,21 +68,18 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
actionLabel.setEmojis(account.emojis, identifier: account.id) actionLabel.setEmojis(account.emojis, identifier: account.id)
} }
let avatarURL = account.avatar let avatarURL = account.avatar
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (data) in avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self, self.account == account, let data = data else { return } guard let self = self else { return }
self.avatarRequest = nil self.avatarRequest = nil
let image: UIImage? guard self.account == account,
if self.isGrayscale { let image = image,
image = ImageGrayscalifier.convert(url: avatarURL, data: data) let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else {
} else { return
image = UIImage(data: data)
} }
if let image = image {
DispatchQueue.main.async { 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) NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
moreButton.addInteraction(UIPointerInteraction(delegate: self)) moreButton.addInteraction(UIPointerInteraction(delegate: self))
if #available(iOS 14.0, *) {
moreButton.showsMenuAsPrimaryAction = true moreButton.showsMenuAsPrimaryAction = true
moreButton.isContextMenuInteractionEnabled = true moreButton.isContextMenuInteractionEnabled = true
} }
}
private func createObservers() { private func createObservers() {
cancellables = [] cancellables = []
@ -110,9 +108,7 @@ class ProfileHeaderView: UIView {
updateImages(account: account) updateImages(account: account)
if #available(iOS 14.0, *) {
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForProfile(accountID: accountID, sourceView: moreButton)) moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForProfile(accountID: accountID, sourceView: moreButton))
}
noteTextView.navigationDelegate = delegate noteTextView.navigationDelegate = delegate
noteTextView.setTextFromHtml(account.note) noteTextView.setTextFromHtml(account.note)
@ -191,35 +187,32 @@ class ProfileHeaderView: UIView {
let accountID = account.id let accountID = account.id
let avatarURL = account.avatar let avatarURL = account.avatar
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (data) in // always load original for avatars, because ImageCache.avatars stores them scaled-down in memory
guard let self = self, let data = data, self.accountID == accountID else { return } 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 self.avatarRequest = nil
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarImageView.image = image self.avatarImageView.image = transformedImage
} }
} }
if let header = account.header { if let header = account.header {
headerRequest = ImageCache.headers.get(header) { [weak self] (data) in headerRequest = ImageCache.headers.get(header) { [weak self] (_, image) in
guard let self = self, let data = data, self.accountID == accountID else { return } guard let self = self,
let image = image,
self.accountID == accountID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: header, image: image) else {
return
}
self.headerRequest = nil self.headerRequest = nil
let image: UIImage?
if self.isGrayscale {
image = ImageGrayscalifier.convert(url: header, data: data)
} else {
image = UIImage(data: data)
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.headerImageView.image = image self.headerImageView.image = transformedImage
} }
} }
} }
@ -227,14 +220,6 @@ class ProfileHeaderView: UIView {
// MARK: Interaction // 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() { @objc func avatarPressed() {
guard let account = mastodonController.persistentContainer.account(for: accountID) else { guard let account = mastodonController.persistentContainer.account(for: accountID) else {
return return
@ -256,7 +241,6 @@ class ProfileHeaderView: UIView {
} }
@available(iOS 13.4, *)
extension ProfileHeaderView: UIPointerInteractionDelegate { extension ProfileHeaderView: UIPointerInteractionDelegate {
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? { func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
let preview = UITargetedPreview(view: moreButton) let preview = UITargetedPreview(view: moreButton)

View File

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="UTF-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"/> <device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18091"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17124"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.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"> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wT9-2J-uSY">
<rect key="frame" x="16" y="134" width="120" height="120"/> <rect key="frame" x="16" y="134" width="120" height="120"/>
<subviews> <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"/> <rect key="frame" x="2" y="2" width="116" height="116"/>
<constraints> <constraints>
<constraint firstAttribute="height" constant="116" id="eDg-Vc-o8R"/> <constraint firstAttribute="height" constant="116" id="eDg-Vc-o8R"/>
@ -58,9 +57,6 @@
<userDefinedRuntimeAttributes> <userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="image" keyPath="image" value="ellipsis" catalog="system"/> <userDefinedRuntimeAttribute type="image" keyPath="image" value="ellipsis" catalog="system"/>
</userDefinedRuntimeAttributes> </userDefinedRuntimeAttributes>
<connections>
<action selector="morePressed:" destination="iN0-l3-epB" eventType="touchUpInside" id="Td6-rw-Xvr"/>
</connections>
</view> </view>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="u4P-3i-gEq"> <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"/> <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 favoriteButton: UIButton!
@IBOutlet weak var reblogButton: UIButton! @IBOutlet weak var reblogButton: UIButton!
@IBOutlet weak var moreButton: UIButton! @IBOutlet weak var moreButton: UIButton!
private(set) var prevThreadLinkView: UIView?
private(set) var nextThreadLinkView: UIView?
var statusID: String! var statusID: String!
var accountID: String! var accountID: String!
@ -94,9 +96,7 @@ class BaseStatusTableViewCell: UITableViewCell {
accessibilityElements = [displayNameLabel!, contentWarningLabel!, collapseButton!, contentTextView!, attachmentsView!] accessibilityElements = [displayNameLabel!, contentWarningLabel!, collapseButton!, contentTextView!, attachmentsView!]
attachmentsView.isAccessibilityElement = true attachmentsView.isAccessibilityElement = true
if #available(iOS 14.0, *) {
moreButton.showsMenuAsPrimaryAction = true moreButton.showsMenuAsPrimaryAction = true
}
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) 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") 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 // 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)) moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForStatus(status, sourceView: moreButton))
} }
}
func updateUI(account: AccountMO) { func updateUI(account: AccountMO) {
usernameLabel.text = "@\(account.acct)" usernameLabel.text = "@\(account.acct)"
@ -260,18 +258,14 @@ class BaseStatusTableViewCell: UITableViewCell {
let avatarURL = account.avatar let avatarURL = account.avatar
let accountID = account.id let accountID = account.id
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (data) in avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self, let data = data, self.accountID == accountID else { return } guard let self = self,
let image = image,
let image: UIImage? self.accountID == accountID,
if self.isGrayscale { let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else { return }
image = ImageGrayscalifier.convert(url: avatarURL, data: data)
} else {
image = UIImage(data: data)
}
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarImageView.image = image self.avatarImageView.image = transformedImage
} }
} }
@ -282,6 +276,52 @@ class BaseStatusTableViewCell: UITableViewCell {
displayNameLabel.updateForAccountDisplayName(account: account) 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() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()

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