Compare commits
36 Commits
dd82283341
...
76fc73de95
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 76fc73de95 | |
Shadowfacts | 40800f964d | |
Shadowfacts | 9f7d16a70e | |
Shadowfacts | c2cb0a0c5a | |
Shadowfacts | 272f35417b | |
Shadowfacts | 848c3dd950 | |
Shadowfacts | dfeb39b31f | |
Shadowfacts | bab5226f2a | |
Shadowfacts | 88cfbfb1f3 | |
Shadowfacts | 49f1d6339f | |
Shadowfacts | 3e7cb443fa | |
Shadowfacts | b5c8a38b9b | |
Shadowfacts | ab19922530 | |
Shadowfacts | 45c844b065 | |
Shadowfacts | 47b838a386 | |
Shadowfacts | 276691efbf | |
Shadowfacts | 0a8d50cc27 | |
Shadowfacts | 11e81acbc1 | |
Shadowfacts | fb2c9b341c | |
Shadowfacts | 810ae71832 | |
Shadowfacts | 001a73af3c | |
Shadowfacts | c8375b742a | |
Shadowfacts | 9feef054fc | |
Shadowfacts | bf87ae7a7d | |
Shadowfacts | f8de6f9e10 | |
Shadowfacts | ab47fa776e | |
Shadowfacts | 7178473f34 | |
Shadowfacts | c8319d8af2 | |
Shadowfacts | 9ff1452c68 | |
Shadowfacts | ce534c4a05 | |
Shadowfacts | 0fddf94292 | |
Shadowfacts | 8276e99d27 | |
Shadowfacts | a5ad8e43b1 | |
Shadowfacts | ce7ce3ac92 | |
Shadowfacts | 99a1c76cb1 | |
Shadowfacts | 603e989879 |
32
CHANGELOG.md
32
CHANGELOG.md
|
@ -1,5 +1,37 @@
|
|||
# Changelog
|
||||
|
||||
## 2022.1 (46)
|
||||
The headlining feature is state restoration and timeline gaps! When you re-open the app after it's been closed for a while, it will remember your position in the timeline and allow you to keep reading from there. It will also let you jump all the way to the present.
|
||||
|
||||
Features/Improvements:
|
||||
- Timeline state restoration, timeline gaps, and jump-to-present
|
||||
- Allow posting wide color gamut images on Mastodon 4
|
||||
- Add Add to List menu action to profiles
|
||||
- Improve More Actions button visibility on dark profile header images
|
||||
- Make poll options in the Compose screen reorderable with drag & drop
|
||||
- Embiggen Share/Close controls in the gallery to make them easier to tap
|
||||
- Separate section for Shared Albums in the Compose attachment picker
|
||||
- Indicate verified profile links with a checkmark and popover explaining what it means
|
||||
- Add a preference for using the Twitter-style keyboard with @ and # (Preferences -> Composing -> Show @ and # on Keyboard)
|
||||
- Improve reblog indicator in timeline
|
||||
|
||||
Bugfixes:
|
||||
- Fix not being able to select an existing draft to edit it
|
||||
- Fix double-tap to zoom in the gallery not working
|
||||
- Fix crash when toggling collapsed posts in Trending Posts
|
||||
- Fix albums in asset picker not being sorted by name
|
||||
- Fix profile headers getting squished when statuses are loaded while the profile is offscreen
|
||||
- Fix error loading posts when server returns rich cards
|
||||
- Fix Akkoma instnaces not being detected as supporting Pleroma features
|
||||
- Fix crash when launching the app in slow network conditions
|
||||
- Fix lists not updating in the UI when renamed
|
||||
- Fix follow/block/mute actions displaying on the user's own profile
|
||||
- Fix Edit List screen being presented repeatedly when switching tabs back to Explore with a list open
|
||||
- Fix reblog visibility icon getting squished in the reblog confirmation dialog when Dynamic Type is active
|
||||
- Fix toasts not adjusting to Dynamic Type size
|
||||
- Don't show duplicate reply/favorite/reblog actions in the status More Actions menu
|
||||
- iPadOS 15: Fix toolbar in Compose window being obscured by the keyboard
|
||||
|
||||
## 2022.1 (45)
|
||||
Features/Improvements:
|
||||
- iPhone: Temporarily hide the Compose screen by swiping down to access the rest of the applies
|
||||
|
|
|
@ -167,5 +167,12 @@ extension Account {
|
|||
public struct Field: Codable {
|
||||
public let name: String
|
||||
public let value: String
|
||||
public let verifiedAt: Date?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case value
|
||||
case verifiedAt = "verified_at"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -79,5 +79,6 @@ extension Card {
|
|||
case link
|
||||
case photo
|
||||
case video
|
||||
case rich
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,11 +17,12 @@ public class List: Decodable, Equatable, Hashable {
|
|||
}
|
||||
|
||||
public static func ==(lhs: List, rhs: List) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
return lhs.id == rhs.id && lhs.title == rhs.title
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
hasher.combine(title)
|
||||
}
|
||||
|
||||
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
|
||||
|
|
|
@ -11,7 +11,9 @@ import Foundation
|
|||
public enum RequestRange {
|
||||
case `default`
|
||||
case count(Int)
|
||||
/// Chronologically immediately before the given ID
|
||||
case before(id: String, count: Int?)
|
||||
/// Chronologically immediately after the given ID
|
||||
case after(id: String, count: Int?)
|
||||
}
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public class StatusState: Equatable, Hashable {
|
||||
public class StatusState: Equatable {
|
||||
public var collapsible: Bool?
|
||||
public var collapsed: Bool?
|
||||
|
||||
|
|
|
@ -200,10 +200,8 @@
|
|||
D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC7B232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib */; };
|
||||
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7E2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift */; };
|
||||
D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC7F2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib */; };
|
||||
D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC832321F6C100FD64D5 /* AccountListTableViewController.swift */; };
|
||||
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */; };
|
||||
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */; };
|
||||
D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC8D2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift */; };
|
||||
D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */; };
|
||||
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */; };
|
||||
D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */; };
|
||||
|
@ -270,6 +268,10 @@
|
|||
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; };
|
||||
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
|
||||
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; };
|
||||
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; };
|
||||
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */; };
|
||||
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */; };
|
||||
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B59292D684600D528E1 /* AccountListViewController.swift */; };
|
||||
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; };
|
||||
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; };
|
||||
D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; };
|
||||
|
@ -562,10 +564,8 @@
|
|||
D6A3BC7B232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ActionNotificationGroupTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D6A3BC7E2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowNotificationGroupTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D6A3BC7F2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FollowNotificationGroupTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D6A3BC832321F6C100FD64D5 /* AccountListTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListTableViewController.swift; sourceTree = "<group>"; };
|
||||
D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D6A3BC8D2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListTableViewController.swift; sourceTree = "<group>"; };
|
||||
D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastAccountSwitcherViewController.swift; sourceTree = "<group>"; };
|
||||
D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FastAccountSwitcherViewController.xib; sourceTree = "<group>"; };
|
||||
D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastSwitchingAccountView.swift; sourceTree = "<group>"; };
|
||||
|
@ -632,6 +632,10 @@
|
|||
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = "<group>"; };
|
||||
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
|
||||
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
|
||||
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListViewController.swift; sourceTree = "<group>"; };
|
||||
D6D12B59292D684600D528E1 /* AccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListViewController.swift; sourceTree = "<group>"; };
|
||||
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = "<group>"; };
|
||||
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = "<group>"; };
|
||||
D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = "<group>"; };
|
||||
|
@ -924,6 +928,7 @@
|
|||
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
|
||||
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
|
||||
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
|
||||
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */,
|
||||
);
|
||||
path = Timeline;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1178,7 +1183,7 @@
|
|||
D6A3BC822321F69400FD64D5 /* Account List */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6A3BC832321F6C100FD64D5 /* AccountListTableViewController.swift */,
|
||||
D6D12B59292D684600D528E1 /* AccountListViewController.swift */,
|
||||
);
|
||||
path = "Account List";
|
||||
sourceTree = "<group>";
|
||||
|
@ -1188,6 +1193,7 @@
|
|||
children = (
|
||||
D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */,
|
||||
D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */,
|
||||
D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */,
|
||||
);
|
||||
path = "Account Cell";
|
||||
sourceTree = "<group>";
|
||||
|
@ -1195,7 +1201,7 @@
|
|||
D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6A3BC8D2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift */,
|
||||
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */,
|
||||
);
|
||||
path = "Status Action Account List";
|
||||
sourceTree = "<group>";
|
||||
|
@ -1613,7 +1619,7 @@
|
|||
TargetAttributes = {
|
||||
D6D4DDCB212518A000E1C4BB = {
|
||||
CreatedOnToolsVersion = 10.0;
|
||||
LastSwiftMigration = 1200;
|
||||
LastSwiftMigration = 1410;
|
||||
};
|
||||
D6D4DDDF212518A200E1C4BB = {
|
||||
CreatedOnToolsVersion = 10.0;
|
||||
|
@ -1832,6 +1838,7 @@
|
|||
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */,
|
||||
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
|
||||
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */,
|
||||
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */,
|
||||
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
|
||||
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */,
|
||||
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
|
||||
|
@ -1851,7 +1858,6 @@
|
|||
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */,
|
||||
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */,
|
||||
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
|
||||
D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */,
|
||||
D64BC18823C1640A000D0238 /* PinStatusActivity.swift in Sources */,
|
||||
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */,
|
||||
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */,
|
||||
|
@ -1870,7 +1876,6 @@
|
|||
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
|
||||
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
|
||||
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
|
||||
D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */,
|
||||
D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */,
|
||||
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
|
||||
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
|
||||
|
@ -1947,6 +1952,7 @@
|
|||
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
|
||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
||||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
|
||||
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */,
|
||||
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
|
||||
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
|
||||
D627943523A5525100D38C68 /* StatusActivity.swift in Sources */,
|
||||
|
@ -2029,11 +2035,13 @@
|
|||
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
||||
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
|
||||
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
|
||||
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
|
||||
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */,
|
||||
D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */,
|
||||
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
|
||||
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */,
|
||||
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
|
||||
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
|
||||
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -2187,7 +2195,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
CURRENT_PROJECT_VERSION = 46;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2255,7 +2263,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
CURRENT_PROJECT_VERSION = 46;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
|
@ -2405,7 +2413,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
CURRENT_PROJECT_VERSION = 46;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2434,7 +2442,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
CURRENT_PROJECT_VERSION = 46;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2544,7 +2552,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
CURRENT_PROJECT_VERSION = 46;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
|
@ -2571,7 +2579,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 45;
|
||||
CURRENT_PROJECT_VERSION = 46;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
|
|
|
@ -99,6 +99,11 @@
|
|||
value = "1"
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "CG_CONTEXT_SHOW_BACKTRACE"
|
||||
value = ""
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "CG_NUMERICS_SHOW_BACKTRACE"
|
||||
value = ""
|
||||
|
|
|
@ -49,7 +49,7 @@ class CreateListService {
|
|||
do {
|
||||
let request = Client.createList(title: title)
|
||||
let (list, _) = try await mastodonController.run(request)
|
||||
NotificationCenter.default.post(name: .listsChanged, object: nil)
|
||||
mastodonController.addedList(list)
|
||||
self.didCreateList?(list)
|
||||
} catch {
|
||||
let alert = UIAlertController(title: "Error Creating List", message: error.localizedDescription, preferredStyle: .alert)
|
||||
|
@ -64,7 +64,3 @@ class CreateListService {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
extension Foundation.Notification.Name {
|
||||
static let listsChanged = Notification.Name("listsChanged")
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ class DeleteListService {
|
|||
do {
|
||||
let request = List.delete(list)
|
||||
_ = try await mastodonController.run(request)
|
||||
NotificationCenter.default.post(name: .listsChanged, object: nil)
|
||||
mastodonController.deletedList(list)
|
||||
} catch {
|
||||
let alert = UIAlertController(title: "Error Deleting List", message: error.localizedDescription, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
|
|
|
@ -11,15 +11,18 @@ import Pachyderm
|
|||
|
||||
struct InstanceFeatures {
|
||||
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive)
|
||||
private static let akkomaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; akkoma (.*)\\)", options: .caseInsensitive)
|
||||
|
||||
private(set) var instanceType = InstanceType.mastodon
|
||||
private(set) var version: Version?
|
||||
private(set) var pleromaVersion: Version?
|
||||
private(set) var hometownVersion: Version?
|
||||
private var instanceType: InstanceType = .mastodon(.vanilla, nil)
|
||||
private(set) var maxStatusChars = 500
|
||||
|
||||
var localOnlyPosts: Bool {
|
||||
instanceType == .hometown || instanceType == .glitch
|
||||
switch instanceType {
|
||||
case .mastodon(.hometown(_), _), .mastodon(.glitch, _):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var mastodonAttachmentRestrictions: Bool {
|
||||
|
@ -27,15 +30,20 @@ struct InstanceFeatures {
|
|||
}
|
||||
|
||||
var pollsAndAttachments: Bool {
|
||||
instanceType == .pleroma
|
||||
instanceType.isPleroma
|
||||
}
|
||||
|
||||
var boostToOriginalAudience: Bool {
|
||||
instanceType == .pleroma || instanceType.isMastodon
|
||||
instanceType.isPleroma || instanceType.isMastodon
|
||||
}
|
||||
|
||||
var profilePinnedStatuses: Bool {
|
||||
instanceType != .pixelfed
|
||||
switch instanceType {
|
||||
case .pixelfed:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var trends: Bool {
|
||||
|
@ -48,45 +56,73 @@ struct InstanceFeatures {
|
|||
|
||||
var reblogVisibility: Bool {
|
||||
(instanceType.isMastodon && hasVersion(2, 8, 0))
|
||||
|| (instanceType == .pleroma && hasPleromaVersion(2, 0, 0))
|
||||
|| (instanceType.isPleroma && hasPleromaVersion(2, 0, 0))
|
||||
}
|
||||
|
||||
var probablySupportsMarkdown: Bool {
|
||||
instanceType == .pleroma || instanceType == .glitch || instanceType == .hometown
|
||||
switch instanceType {
|
||||
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var needsLocalOnlyEmojiHack: Bool {
|
||||
if case .mastodon(.glitch, _) = instanceType {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var needsWideColorGamutHack: Bool {
|
||||
if case .mastodon(_, .some(let version)) = instanceType {
|
||||
return version < Version(4, 0, 0)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
|
||||
var version: Version?
|
||||
|
||||
let ver = instance.version.lowercased()
|
||||
if ver.contains("glitch") {
|
||||
instanceType = .glitch
|
||||
instanceType = .mastodon(.glitch, Version(string: ver))
|
||||
} else if nodeInfo?.software.name == "hometown" {
|
||||
instanceType = .hometown
|
||||
var mastoVersion: Version?
|
||||
var hometownVersion: Version?
|
||||
// like "1.0.6+3.5.2"
|
||||
let parts = ver.split(separator: "+")
|
||||
if parts.count == 2 {
|
||||
version = Version(string: String(parts[1]))
|
||||
mastoVersion = Version(string: String(parts[1]))
|
||||
hometownVersion = Version(string: String(parts[0]))
|
||||
} else {
|
||||
mastoVersion = Version(string: ver)
|
||||
}
|
||||
instanceType = .mastodon(.hometown(hometownVersion), mastoVersion)
|
||||
} else if ver.contains("pleroma") {
|
||||
instanceType = .pleroma
|
||||
var pleromaVersion: Version?
|
||||
if let match = InstanceFeatures.pleromaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) {
|
||||
pleromaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 1)))
|
||||
}
|
||||
instanceType = .pleroma(.vanilla(pleromaVersion))
|
||||
} else if ver.contains("akkoma") {
|
||||
var akkomaVersion: Version?
|
||||
if let match = InstanceFeatures.akkomaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) {
|
||||
akkomaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 1)))
|
||||
}
|
||||
instanceType = .pleroma(.akkoma(akkomaVersion))
|
||||
} else if ver.contains("pixelfed") {
|
||||
instanceType = .pixelfed
|
||||
} else {
|
||||
instanceType = .mastodon
|
||||
instanceType = .mastodon(.vanilla, Version(string: ver))
|
||||
}
|
||||
|
||||
self.version = version ?? Version(string: ver)
|
||||
|
||||
maxStatusChars = instance.maxStatusCharacters ?? 500
|
||||
}
|
||||
|
||||
func hasVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
||||
if let version {
|
||||
if case .mastodon(_, .some(let version)) = instanceType {
|
||||
return version >= Version(major, minor, patch)
|
||||
} else {
|
||||
return false
|
||||
|
@ -94,30 +130,47 @@ struct InstanceFeatures {
|
|||
}
|
||||
|
||||
func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
||||
if let pleromaVersion {
|
||||
return pleromaVersion >= Version(major, minor, patch)
|
||||
} else {
|
||||
switch instanceType {
|
||||
case .pleroma(.vanilla(.some(let version))), .pleroma(.akkoma(.some(let version))):
|
||||
return version >= Version(major, minor, patch)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension InstanceFeatures {
|
||||
enum InstanceType: Equatable {
|
||||
case mastodon // vanilla
|
||||
case pleroma
|
||||
case hometown
|
||||
case glitch
|
||||
enum InstanceType {
|
||||
case mastodon(MastodonType, Version?)
|
||||
case pleroma(PleromaType)
|
||||
case pixelfed
|
||||
|
||||
var isMastodon: Bool {
|
||||
switch self {
|
||||
case .mastodon, .hometown, .glitch:
|
||||
if case .mastodon(_, _) = self {
|
||||
return true
|
||||
default:
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isPleroma: Bool {
|
||||
if case .pleroma(_) = self {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum MastodonType {
|
||||
case vanilla
|
||||
case hometown(Version?)
|
||||
case glitch
|
||||
}
|
||||
|
||||
enum PleromaType {
|
||||
case vanilla(Version?)
|
||||
case akkoma(Version?)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -46,6 +46,7 @@ class MastodonController: ObservableObject {
|
|||
@Published private(set) var instance: Instance!
|
||||
@Published private(set) var nodeInfo: NodeInfo!
|
||||
@Published private(set) var instanceFeatures = InstanceFeatures()
|
||||
@Published private(set) var lists: [List] = []
|
||||
private(set) var customEmojis: [Emoji]?
|
||||
|
||||
private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]()
|
||||
|
@ -119,6 +120,15 @@ class MastodonController: ObservableObject {
|
|||
})
|
||||
}
|
||||
|
||||
func initialize() async throws {
|
||||
async let ownAccount = try getOwnAccount()
|
||||
async let ownInstance = try getOwnInstance()
|
||||
|
||||
_ = try await (ownAccount, ownInstance)
|
||||
|
||||
loadLists()
|
||||
}
|
||||
|
||||
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
|
||||
if account != nil {
|
||||
completion?(.success(account))
|
||||
|
@ -264,4 +274,53 @@ class MastodonController: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private func loadLists() {
|
||||
let req = Client.getLists()
|
||||
run(req) { response in
|
||||
if case .success(let lists, _) = response {
|
||||
DispatchQueue.main.async {
|
||||
self.lists = lists.sorted(using: ListComparator())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func addedList(_ list: List) {
|
||||
var new = self.lists
|
||||
new.append(list)
|
||||
new.sort { $0.title < $1.title }
|
||||
self.lists = new
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func deletedList(_ list: List) {
|
||||
self.lists.removeAll(where: { $0.id == list.id })
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func renamedList(_ list: List) {
|
||||
var new = self.lists
|
||||
if let index = new.firstIndex(where: { $0.id == list.id }) {
|
||||
new[index] = list
|
||||
}
|
||||
new.sort(using: ListComparator())
|
||||
self.lists = new
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct ListComparator: SortComparator {
|
||||
typealias Compared = List
|
||||
|
||||
var underlying = String.Comparator(options: .caseInsensitive)
|
||||
|
||||
var order: SortOrder {
|
||||
get { underlying.order }
|
||||
set { underlying.order = newValue }
|
||||
}
|
||||
|
||||
func compare(_ lhs: List, _ rhs: List) -> ComparisonResult {
|
||||
return underlying.compare(lhs.title, rhs.title)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ class PostService: ObservableObject {
|
|||
let sensitive = contentWarning != nil
|
||||
|
||||
let request = Client.createStatus(
|
||||
text: draft.textForPosting(on: mastodonController.instanceFeatures),
|
||||
text: textForPosting(),
|
||||
contentType: Preferences.shared.statusContentType,
|
||||
inReplyTo: draft.inReplyToID,
|
||||
media: uploadedAttachments,
|
||||
|
@ -87,7 +87,7 @@ class PostService: ObservableObject {
|
|||
|
||||
private func getData(for attachment: CompositionAttachment) async throws -> (Data, UTType) {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
attachment.data.getData { result in
|
||||
attachment.data.getData(features: mastodonController.instanceFeatures) { result in
|
||||
switch result {
|
||||
case let .success(res):
|
||||
continuation.resume(returning: res)
|
||||
|
@ -104,6 +104,19 @@ class PostService: ObservableObject {
|
|||
return try await mastodonController.run(req).0
|
||||
}
|
||||
|
||||
private func textForPosting() -> String {
|
||||
var text = draft.text
|
||||
// when using dictation, iOS sometimes leaves a U+FFFC OBJECT REPLACEMENT CHARACTER behind in the text,
|
||||
// which we want to strip out before actually posting the status
|
||||
text = text.replacingOccurrences(of: "\u{fffc}", with: "")
|
||||
|
||||
if draft.localOnly && mastodonController.instanceFeatures.needsLocalOnlyEmojiHack {
|
||||
text += " 👁"
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
enum Error: Swift.Error, LocalizedError {
|
||||
case attachmentData(index: Int, cause: CompositionAttachmentData.Error)
|
||||
case attachmentUpload(index: Int, cause: Client.Error)
|
||||
|
|
|
@ -49,7 +49,7 @@ class RenameListService {
|
|||
do {
|
||||
let req = List.update(list, title: title)
|
||||
let (list, _) = try await mastodonController.run(req)
|
||||
NotificationCenter.default.post(name: .listRenamed, object: list.id, userInfo: ["list": list])
|
||||
mastodonController.renamedList(list)
|
||||
} catch {
|
||||
let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
|
@ -63,7 +63,3 @@ class RenameListService {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
extension Foundation.Notification.Name {
|
||||
static let listRenamed = Notification.Name("listRenamed")
|
||||
}
|
||||
|
|
|
@ -51,7 +51,7 @@ enum CompositionAttachmentData {
|
|||
}
|
||||
}
|
||||
|
||||
func getData(completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
|
||||
func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
|
||||
switch self {
|
||||
case let .image(image):
|
||||
// Export as JPEG instead of PNG, otherweise photos straight from the camera are too large
|
||||
|
@ -71,21 +71,26 @@ enum CompositionAttachmentData {
|
|||
return
|
||||
}
|
||||
|
||||
guard !skipAllConversion else {
|
||||
completion(.success((data, UTType(dataUTI)!)))
|
||||
return
|
||||
}
|
||||
|
||||
let utType: UTType
|
||||
let image = CIImage(data: data)!
|
||||
let needsColorSpaceConversion = image.colorSpace?.name != CGColorSpace.sRGB
|
||||
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
|
||||
|
||||
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
|
||||
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
|
||||
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
|
||||
if needsColorSpaceConversion || dataUTI == "public.heic" {
|
||||
let context = CIContext()
|
||||
let sRGB = CGColorSpace(name: CGColorSpace.sRGB)!
|
||||
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
|
||||
if dataUTI == "public.png" {
|
||||
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: sRGB)!
|
||||
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)!
|
||||
utType = .png
|
||||
} else {
|
||||
data = context.jpegRepresentation(of: image, colorSpace: sRGB)!
|
||||
data = context.jpegRepresentation(of: image, colorSpace: colorSpace)!
|
||||
utType = .jpeg
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -86,19 +86,6 @@ class Draft: Codable, ObservableObject {
|
|||
|
||||
try container.encode(initialText, forKey: .initialText)
|
||||
}
|
||||
|
||||
func textForPosting(on instance: InstanceFeatures) -> String {
|
||||
var text = self.text
|
||||
// when using dictation, iOS sometimes leaves a U+FFFC OBJECT REPLACEMENT CHARACTER behind in the text,
|
||||
// which we want to strip out before actually posting the status
|
||||
text = text.replacingOccurrences(of: "\u{fffc}", with: "")
|
||||
|
||||
if localOnly && instance.instanceType == .glitch {
|
||||
text += " 👁"
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
extension Draft: Equatable {
|
||||
|
|
|
@ -50,6 +50,7 @@ class Preferences: Codable, ObservableObject {
|
|||
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
|
||||
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
|
||||
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
|
||||
self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false
|
||||
|
||||
self.blurAllMedia = try container.decode(Bool.self, forKey: .blurAllMedia)
|
||||
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
|
||||
|
@ -91,6 +92,7 @@ class Preferences: Codable, ObservableObject {
|
|||
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
|
||||
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
|
||||
try container.encode(mentionReblogger, forKey: .mentionReblogger)
|
||||
try container.encode(useTwitterKeyboard, forKey: .useTwitterKeyboard)
|
||||
|
||||
try container.encode(blurAllMedia, forKey: .blurAllMedia)
|
||||
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
|
||||
|
@ -131,6 +133,7 @@ class Preferences: Codable, ObservableObject {
|
|||
@Published var requireAttachmentDescriptions = false
|
||||
@Published var contentWarningCopyMode = ContentWarningCopyMode.asIs
|
||||
@Published var mentionReblogger = false
|
||||
@Published var useTwitterKeyboard = false
|
||||
|
||||
// MARK: Media
|
||||
@Published var blurAllMedia = false {
|
||||
|
@ -181,6 +184,7 @@ class Preferences: Codable, ObservableObject {
|
|||
case requireAttachmentDescriptions
|
||||
case contentWarningCopyMode
|
||||
case mentionReblogger
|
||||
case useTwitterKeyboard
|
||||
|
||||
case blurAllMedia
|
||||
case blurMediaBehindContentWarning
|
||||
|
|
|
@ -42,9 +42,9 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
|
||||
let controller = MastodonController.getForAccount(account)
|
||||
session.mastodonController = controller
|
||||
|
||||
controller.getOwnAccount()
|
||||
controller.getOwnInstance()
|
||||
Task {
|
||||
try? await controller.initialize()
|
||||
}
|
||||
|
||||
guard let rootVC = viewController(for: activity, mastodonController: controller) else {
|
||||
UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil)
|
||||
|
|
|
@ -50,8 +50,9 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
}
|
||||
|
||||
session.mastodonController = controller
|
||||
controller.getOwnAccount()
|
||||
controller.getOwnInstance()
|
||||
Task {
|
||||
try? await controller.initialize()
|
||||
}
|
||||
|
||||
let composeVC = ComposeHostingController(draft: draft, mastodonController: controller)
|
||||
composeVC.delegate = self
|
||||
|
|
|
@ -95,8 +95,15 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
}
|
||||
|
||||
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
|
||||
if let mastodonController = window?.windowScene?.session.mastodonController {
|
||||
return UserActivityManager.mainSceneActivity(accountID: mastodonController.accountInfo!.id)
|
||||
if let mastodonController = window?.windowScene?.session.mastodonController {
|
||||
if let vcActivity = rootViewController?.stateRestorationActivity() {
|
||||
vcActivity.isStateRestorationActivity = true
|
||||
stateRestorationLogger.info("MainSceneDelegate returning stateRestorationActivity of type \(vcActivity.activityType, privacy: .public) from VC")
|
||||
return vcActivity
|
||||
} else {
|
||||
// need to have an activity to make sure the same account is used
|
||||
return UserActivityManager.mainSceneActivity(accountID: mastodonController.accountInfo!.id)
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
@ -144,7 +151,6 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
func showAppOrOnboardingUI(session: UISceneSession? = nil) {
|
||||
let session = session ?? window!.windowScene!.session
|
||||
if LocalData.shared.onboardingComplete {
|
||||
|
@ -162,9 +168,12 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
|
||||
activateAccount(account, animated: false)
|
||||
|
||||
if let activity = launchActivity,
|
||||
activity.activityType != UserActivityType.mainScene.rawValue {
|
||||
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!))
|
||||
if let activity = launchActivity {
|
||||
if activity.isStateRestorationActivity {
|
||||
rootViewController?.restoreActivity(activity)
|
||||
} else if activity.activityType != UserActivityType.mainScene.rawValue {
|
||||
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
window!.rootViewController = createOnboardingUI()
|
||||
|
@ -203,8 +212,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
|
||||
func createAppUI() -> TuskerRootViewController {
|
||||
let mastodonController = window!.windowScene!.session.mastodonController!
|
||||
mastodonController.getOwnAccount()
|
||||
mastodonController.getOwnInstance()
|
||||
Task {
|
||||
try? await mastodonController.initialize()
|
||||
}
|
||||
|
||||
let split = MainSplitViewController(mastodonController: mastodonController)
|
||||
if UIDevice.current.userInterfaceIdiom == .phone,
|
||||
|
|
|
@ -1,75 +0,0 @@
|
|||
//
|
||||
// AccountListTableViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 9/5/19.
|
||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class AccountListTableViewController: EnhancedTableViewController {
|
||||
|
||||
private let accountCell = "accountCell"
|
||||
|
||||
let mastodonController: MastodonController
|
||||
|
||||
let accountIDs: [String]
|
||||
|
||||
init(accountIDs: [String], mastodonController: MastodonController) {
|
||||
self.accountIDs = accountIDs
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
super.init(style: .grouped)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
dragEnabled = true
|
||||
|
||||
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell)
|
||||
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 66
|
||||
|
||||
tableView.alwaysBounceVertical = true
|
||||
|
||||
tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: CGFloat.leastNormalMagnitude))
|
||||
}
|
||||
|
||||
// MARK: - Table view data source
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return 1
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return accountIDs.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: accountCell, for: indexPath) as? AccountTableViewCell else { fatalError() }
|
||||
|
||||
let id = accountIDs[indexPath.row]
|
||||
cell.delegate = self
|
||||
cell.updateUI(accountID: id)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AccountListTableViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
}
|
||||
|
||||
extension AccountListTableViewController: ToastableViewController {
|
||||
}
|
||||
|
||||
extension AccountListTableViewController: MenuActionProvider {
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
//
|
||||
// AccountListViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 9/5/19.
|
||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class AccountListViewController: UIViewController {
|
||||
typealias Item = String
|
||||
|
||||
private let mastodonController: MastodonController
|
||||
private let accountIDs: [String]
|
||||
|
||||
private var collectionView: UICollectionView {
|
||||
view as! UICollectionView
|
||||
}
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
|
||||
init(accountIDs: [String], mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
self.accountIDs = accountIDs
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
let config = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||
let layout = UICollectionViewCompositionalLayout.list(using: config)
|
||||
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collectionView.delegate = self
|
||||
collectionView.dragDelegate = self
|
||||
dataSource = createDataSource()
|
||||
}
|
||||
|
||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, item in
|
||||
cell.delegate = self
|
||||
cell.updateUI(accountID: item)
|
||||
}
|
||||
return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
|
||||
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: itemIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.accounts])
|
||||
snapshot.appendItems(accountIDs)
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AccountListViewController {
|
||||
enum Section {
|
||||
case accounts
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountListViewController: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
if let id = dataSource.itemIdentifier(for: indexPath) {
|
||||
selected(account: id)
|
||||
}
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard let id = dataSource.itemIdentifier(for: indexPath),
|
||||
let cell = collectionView.cellForItem(at: indexPath) else {
|
||||
return nil
|
||||
}
|
||||
return UIContextMenuConfiguration {
|
||||
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
|
||||
} actionProvider: { _ in
|
||||
UIMenu(children: self.actionsForProfile(accountID: id, sourceView: cell))
|
||||
}
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountListViewController: UICollectionViewDragDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
|
||||
guard let id = dataSource.itemIdentifier(for: indexPath),
|
||||
let currentAccountID = mastodonController.accountInfo?.id,
|
||||
let account = mastodonController.persistentContainer.account(for: id) else {
|
||||
return []
|
||||
}
|
||||
let provider = NSItemProvider(object: account.url as NSURL)
|
||||
let activity = UserActivityManager.showProfileActivity(id: id, accountID: currentAccountID)
|
||||
activity.displaysAuxiliaryScene = true
|
||||
provider.registerObject(activity, visibility: .all)
|
||||
return [UIDragItem(itemProvider: provider)]
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountListViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
}
|
||||
|
||||
extension AccountListViewController: MenuActionProvider {
|
||||
}
|
||||
|
||||
extension AccountListViewController: StatusBarTappableViewController {
|
||||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
||||
collectionView.scrollToTop()
|
||||
return .stop
|
||||
}
|
||||
}
|
|
@ -46,30 +46,39 @@ class AssetCollectionsListViewController: UITableViewController {
|
|||
})
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.system, .albums, .smartAlbums])
|
||||
snapshot.appendSections([.system, .albums, .sharedAlbums, .smartAlbums])
|
||||
snapshot.appendItems([.cameraRoll], toSection: .system)
|
||||
|
||||
let smartAlbums = PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .any, options: nil)
|
||||
var smartAlbumItems = [Item]()
|
||||
smartAlbums.enumerateObjects { (collection, _, _) in
|
||||
guard collection.assetCollectionSubtype != .smartAlbumAllHidden && collection.assetCollectionSubtype != .smartAlbumRecentlyAdded else {
|
||||
guard collection.assetCollectionSubtype != .smartAlbumAllHidden else {
|
||||
return
|
||||
}
|
||||
smartAlbumItems.append(.album(collection))
|
||||
}
|
||||
// sort these manually, using PHFetchOptions.sortDescriptors seems like it just doesn't work with fetchAssetCollections
|
||||
smartAlbumItems.sort(by: { $0.title < $1.title })
|
||||
snapshot.appendItems(smartAlbumItems, toSection: .smartAlbums)
|
||||
|
||||
let albums = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .any, options: nil)
|
||||
var albumItems = [Item]()
|
||||
var sharedItems = [Item]()
|
||||
albums.enumerateObjects { (collection, _, _) in
|
||||
if collection.estimatedAssetCount > 0 {
|
||||
albumItems.append(.album(collection))
|
||||
if collection.assetCollectionSubtype == .albumCloudShared {
|
||||
sharedItems.append(.album(collection))
|
||||
} else {
|
||||
albumItems.append(.album(collection))
|
||||
}
|
||||
}
|
||||
}
|
||||
albumItems.sort(by: { $0.title < $1.title })
|
||||
sharedItems.sort(by: { $0.title < $1.title })
|
||||
snapshot.appendItems(albumItems, toSection: .albums)
|
||||
snapshot.appendItems(sharedItems, toSection: .sharedAlbums)
|
||||
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Table view delegate
|
||||
|
@ -103,6 +112,7 @@ extension AssetCollectionsListViewController {
|
|||
enum Section {
|
||||
case system
|
||||
case albums
|
||||
case sharedAlbums
|
||||
case smartAlbums
|
||||
}
|
||||
enum Item: Hashable {
|
||||
|
@ -118,15 +128,26 @@ extension AssetCollectionsListViewController {
|
|||
hasher.combine(collection.localIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .cameraRoll:
|
||||
return "All Photos"
|
||||
case .album(let collection):
|
||||
return collection.localizedTitle ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
class DataSource: UITableViewDiffableDataSource<Section, Item> {
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
let currentSnapshot = snapshot()
|
||||
if currentSnapshot.indexOfSection(.albums) == section {
|
||||
switch sectionIdentifier(for: section) {
|
||||
case .albums:
|
||||
return NSLocalizedString("Albums", comment: "albums section title")
|
||||
} else if currentSnapshot.indexOfSection(.smartAlbums) == section {
|
||||
case .sharedAlbums:
|
||||
return NSLocalizedString("Shared Albums", comment: "shared albums section title")
|
||||
case .smartAlbums:
|
||||
return NSLocalizedString("Smart Albums", comment: "smart albums section title")
|
||||
} else {
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ struct ComposeAttachmentRow: View {
|
|||
@ObservedObject var draft: Draft
|
||||
@ObservedObject var attachment: CompositionAttachment
|
||||
|
||||
@EnvironmentObject var mastodonController: MastodonController
|
||||
@EnvironmentObject var uiState: ComposeUIState
|
||||
@State private var mode: Mode = .allowEntry
|
||||
@State private var isShowingTextRecognitionFailedAlert = false
|
||||
|
@ -90,7 +91,7 @@ struct ComposeAttachmentRow: View {
|
|||
mode = .recognizingText
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
self.attachment.data.getData { (result) in
|
||||
self.attachment.data.getData(features: mastodonController.instanceFeatures, skipAllConversion: true) { (result) in
|
||||
let data: Data
|
||||
do {
|
||||
try data = result.get().0
|
||||
|
|
|
@ -51,9 +51,20 @@ struct ComposePollView: View {
|
|||
.hoverEffect()
|
||||
}
|
||||
|
||||
ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in
|
||||
ComposePollOption(poll: poll, option: e.element, optionIndex: e.offset)
|
||||
List {
|
||||
ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in
|
||||
ComposePollOption(poll: poll, option: e.element, optionIndex: e.offset)
|
||||
.frame(height: 36)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.onMove { indices, newIndex in
|
||||
poll.options.move(fromOffsets: indices, toOffset: newIndex)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.frame(height: 44 * CGFloat(poll.options.count))
|
||||
|
||||
Button(action: self.addOption) {
|
||||
Label("Add Option", systemImage: "plus")
|
||||
|
|
|
@ -42,18 +42,20 @@ import Combine
|
|||
}
|
||||
|
||||
struct ComposeView: View {
|
||||
@ObservedObject var draft: Draft
|
||||
@ObservedObject var mastodonController: MastodonController
|
||||
@ObservedObject var uiState: ComposeUIState
|
||||
var draft: Draft {
|
||||
uiState.draft
|
||||
}
|
||||
|
||||
@State private var globalFrameOutsideList: CGRect = .zero
|
||||
@State private var contentWarningBecomeFirstResponder = false
|
||||
@State private var mainComposeTextViewBecomeFirstResponder = false
|
||||
@StateObject private var keyboardReader = KeyboardReader()
|
||||
|
||||
@OptionalStateObject private var poster: PostService?
|
||||
@State private var isShowingPostErrorAlert = false
|
||||
@State private var postError: PostService.Error?
|
||||
|
||||
private var isPosting: Bool {
|
||||
poster != nil
|
||||
}
|
||||
|
@ -61,7 +63,6 @@ struct ComposeView: View {
|
|||
private let stackPadding: CGFloat = 8
|
||||
|
||||
init(mastodonController: MastodonController, uiState: ComposeUIState) {
|
||||
self.draft = uiState.draft
|
||||
self.mastodonController = mastodonController
|
||||
self.uiState = uiState
|
||||
}
|
||||
|
@ -107,6 +108,8 @@ struct ComposeView: View {
|
|||
|
||||
ComposeToolbar(draft: draft)
|
||||
}
|
||||
// on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
|
||||
.padding(.bottom, keyboardInset)
|
||||
.transition(.move(edge: .bottom))
|
||||
}
|
||||
}
|
||||
|
@ -135,6 +138,17 @@ struct ComposeView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
private var keyboardInset: CGFloat {
|
||||
if #unavailable(iOS 16.0),
|
||||
UIDevice.current.userInterfaceIdiom == .pad,
|
||||
keyboardReader.isVisible {
|
||||
return 44
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var autocompleteSuggestions: some View {
|
||||
if let state = uiState.autocompleteState {
|
||||
|
@ -161,7 +175,7 @@ struct ComposeView: View {
|
|||
|
||||
if draft.contentWarningEnabled {
|
||||
ComposeEmojiTextField(
|
||||
text: $draft.contentWarning,
|
||||
text: $uiState.draft.contentWarning,
|
||||
placeholder: "Write your warning here",
|
||||
becomeFirstResponder: $contentWarningBecomeFirstResponder,
|
||||
focusNextView: $mainComposeTextViewBecomeFirstResponder
|
||||
|
@ -316,6 +330,26 @@ private struct GlobalFrameOutsideListPrefKey: PreferenceKey {
|
|||
}
|
||||
}
|
||||
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
private class KeyboardReader: ObservableObject {
|
||||
@Published var isVisible = false
|
||||
|
||||
init() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(willHide), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc func willShow(_ notification: Foundation.Notification) {
|
||||
// when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible"
|
||||
let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
|
||||
isVisible = endFrame.height > 72
|
||||
}
|
||||
|
||||
@objc func willHide() {
|
||||
isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
//struct ComposeView_Previews: PreviewProvider {
|
||||
// static var previews: some View {
|
||||
// ComposeView()
|
||||
|
|
|
@ -79,6 +79,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
|
||||
@EnvironmentObject var uiState: ComposeUIState
|
||||
@EnvironmentObject var mastodonController: MastodonController
|
||||
@ObservedObject var preferences = Preferences.shared
|
||||
@Environment(\.isEnabled) var isEnabled: Bool
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
|
@ -101,6 +102,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
}
|
||||
|
||||
uiView.isEditable = isEnabled
|
||||
uiView.keyboardType = preferences.useTwitterKeyboard ? .twitter : .default
|
||||
|
||||
context.coordinator.text = $text
|
||||
context.coordinator.didChange = textDidChange
|
||||
|
|
|
@ -24,6 +24,8 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
|||
|
||||
var searchControllerStatusOnAppearance: Bool? = nil
|
||||
|
||||
private var listsCancellable: AnyCancellable?
|
||||
|
||||
init(mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
|
@ -70,9 +72,10 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
|||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(reloadLists), name: .listsChanged, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
|
||||
listsCancellable = mastodonController.$lists
|
||||
.sink { [unowned self] in self.reloadLists($0) }
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
|
@ -141,7 +144,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
|||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections(Section.allCases.filter { $0 != .discover })
|
||||
snapshot.appendItems([.bookmarks], toSection: .bookmarks)
|
||||
if mastodonController.instanceFeatures.instanceType.isMastodon,
|
||||
if mastodonController.instanceFeatures.trends,
|
||||
!Preferences.shared.hideDiscover {
|
||||
addDiscoverSection(to: &snapshot)
|
||||
}
|
||||
|
@ -158,7 +161,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
|||
snapshot.appendItems([.findInstance], toSection: .savedInstances)
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
reloadLists()
|
||||
reloadLists(mastodonController.lists)
|
||||
}
|
||||
|
||||
private func addDiscoverSection(to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
|
||||
|
@ -172,7 +175,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
|||
|
||||
private func ownInstanceLoaded(_ instance: Instance) {
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
if mastodonController.instanceFeatures.instanceType.isMastodon,
|
||||
if mastodonController.instanceFeatures.trends,
|
||||
!snapshot.sectionIdentifiers.contains(.discover) {
|
||||
snapshot.insertSections([.discover], afterSection: .bookmarks)
|
||||
snapshot.appendItems([.trendingTags, .profileDirectory], toSection: .discover)
|
||||
|
@ -180,39 +183,13 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
|||
self.dataSource.apply(snapshot)
|
||||
}
|
||||
|
||||
@objc private func reloadLists() {
|
||||
let request = Client.getLists()
|
||||
mastodonController.run(request) { (response) in
|
||||
guard case let .success(lists, _) = response else {
|
||||
return
|
||||
}
|
||||
private func reloadLists(_ lists: [List]) {
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .lists))
|
||||
snapshot.appendItems(lists.map { .list($0) }, toSection: .lists)
|
||||
snapshot.appendItems([.addList], toSection: .lists)
|
||||
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .lists))
|
||||
snapshot.appendItems(lists.map { .list($0) }, toSection: .lists)
|
||||
snapshot.appendItems([.addList], toSection: .lists)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.dataSource.apply(snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func listRenamed(_ notification: Foundation.Notification) {
|
||||
let list = notification.userInfo!["list"] as! List
|
||||
var snapshot = dataSource.snapshot()
|
||||
let existing = snapshot.itemIdentifiers(inSection: .lists).first(where: {
|
||||
if case .list(let existingList) = $0, existingList.id == list.id {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
})
|
||||
if let existing {
|
||||
snapshot.insertItems([.list(list)], afterItem: existing)
|
||||
snapshot.deleteItems([existing])
|
||||
dataSource.apply(snapshot)
|
||||
}
|
||||
self.dataSource.apply(snapshot)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
|
@ -97,7 +97,7 @@ class TrendingStatusesViewController: UIViewController {
|
|||
do {
|
||||
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
|
||||
} catch {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
await dataSource.apply(snapshot)
|
||||
let config = ToastConfiguration(from: error, with: "Loading Trending Posts", in: self) { toast in
|
||||
toast.dismissToast(animated: true)
|
||||
|
@ -122,6 +122,27 @@ extension TrendingStatusesViewController {
|
|||
case status(id: String, state: StatusState)
|
||||
case loadingIndicator
|
||||
|
||||
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.status(id: let a, state: _), .status(id: let b, state: _)):
|
||||
return a == b
|
||||
case (.loadingIndicator, .loadingIndicator):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
switch self {
|
||||
case .status(id: let id, state: _):
|
||||
hasher.combine(0)
|
||||
hasher.combine(id)
|
||||
case .loadingIndicator:
|
||||
hasher.combine(1)
|
||||
}
|
||||
}
|
||||
|
||||
var hideSeparators: Bool {
|
||||
if case .loadingIndicator = self {
|
||||
return true
|
||||
|
|
|
@ -14,6 +14,7 @@ import VisionKit
|
|||
protocol LargeImageContentView: UIView {
|
||||
var animationImage: UIImage? { get }
|
||||
var activityItemsForSharing: [Any] { get }
|
||||
var owner: LargeImageViewController? { get set }
|
||||
func setControlsVisible(_ controlsVisible: Bool)
|
||||
func grayscaleStateChanged()
|
||||
}
|
||||
|
@ -29,17 +30,14 @@ class LargeImageImageContentView: UIImageView, LargeImageContentView {
|
|||
#endif
|
||||
|
||||
var animationImage: UIImage? { image! }
|
||||
|
||||
var activityItemsForSharing: [Any] {
|
||||
[image!]
|
||||
}
|
||||
weak var owner: LargeImageViewController?
|
||||
|
||||
private var sourceData: Data?
|
||||
private weak var owner: UIViewController?
|
||||
|
||||
init(image: UIImage, owner: UIViewController?) {
|
||||
self.owner = owner
|
||||
|
||||
init(image: UIImage) {
|
||||
super.init(image: image)
|
||||
|
||||
contentMode = .scaleAspectFit
|
||||
|
@ -109,11 +107,11 @@ extension LargeImageImageContentView: ImageAnalysisInteractionDelegate {
|
|||
|
||||
class LargeImageGifContentView: GIFImageView, LargeImageContentView {
|
||||
var animationImage: UIImage? { image }
|
||||
|
||||
var activityItemsForSharing: [Any] {
|
||||
// todo: should gifs share the data?
|
||||
[image].compactMap { $0 }
|
||||
}
|
||||
weak var owner: LargeImageViewController?
|
||||
|
||||
init(gifController: GIFController) {
|
||||
super.init(image: gifController.lastFrame?.image)
|
||||
|
@ -144,6 +142,7 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
|
|||
// some SO posts indicate that just sharing a URL to the video should work, but that may need to be a local URL?
|
||||
[]
|
||||
}
|
||||
weak var owner: LargeImageViewController?
|
||||
|
||||
private let asset: AVURLAsset
|
||||
|
||||
|
|
|
@ -17,17 +17,16 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
|
||||
@IBOutlet weak var scrollView: UIScrollView!
|
||||
@IBOutlet weak var topControlsView: UIView!
|
||||
@IBOutlet weak var topControlsHeightConstraint: NSLayoutConstraint!
|
||||
@IBOutlet weak var shareButton: UIButton!
|
||||
@IBOutlet weak var shareButtonTopConstraint: NSLayoutConstraint!
|
||||
@IBOutlet weak var shareButtonLeadingConstraint: NSLayoutConstraint!
|
||||
@IBOutlet weak var closeButton: UIButton!
|
||||
@IBOutlet weak var closeButtonTopConstraint: NSLayoutConstraint!
|
||||
@IBOutlet weak var closeButtonTrailingConstraint: NSLayoutConstraint!
|
||||
|
||||
@IBOutlet weak var bottomControlsView: UIView!
|
||||
@IBOutlet weak var descriptionLabel: UILabel!
|
||||
|
||||
private var shareContainer: UIView!
|
||||
private var shareImage: UIImageView!
|
||||
private var shareButtonTopConstraint: NSLayoutConstraint!
|
||||
private var shareButtonLeadingConstraint: NSLayoutConstraint!
|
||||
private var closeButtonTopConstraint: NSLayoutConstraint!
|
||||
private var closeButtonTrailingConstraint: NSLayoutConstraint!
|
||||
|
||||
var contentView: LargeImageContentView {
|
||||
didSet {
|
||||
oldValue.removeFromSuperview()
|
||||
|
@ -86,9 +85,13 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
super.viewDidLoad()
|
||||
|
||||
setupContentView()
|
||||
setupControls()
|
||||
|
||||
setControlsVisible(initialControlsVisible, animated: false)
|
||||
shareButton.isEnabled = !contentView.activityItemsForSharing.isEmpty
|
||||
if contentView.activityItemsForSharing.isEmpty {
|
||||
shareContainer.isUserInteractionEnabled = false
|
||||
shareImage.tintColor = .systemGray
|
||||
}
|
||||
|
||||
scrollView.delegate = self
|
||||
|
||||
|
@ -103,15 +106,19 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
dismissInteractionController = LargeImageInteractionController(viewController: self)
|
||||
}
|
||||
|
||||
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(scrollViewPressed(_:))))
|
||||
let singleTap = UITapGestureRecognizer(target: self, action: #selector(scrollViewPressed(_:)))
|
||||
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(scrollViewDoubleTapped(_:)))
|
||||
doubleTap.numberOfTapsRequired = 2
|
||||
// this requirement is needed to make sure the double tap is ever recognized
|
||||
singleTap.require(toFail: doubleTap)
|
||||
view.addGestureRecognizer(singleTap)
|
||||
view.addGestureRecognizer(doubleTap)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
}
|
||||
|
||||
private func setupContentView() {
|
||||
contentView.owner = self
|
||||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.addSubview(contentView)
|
||||
contentViewLeadingConstraint = contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
|
||||
|
@ -122,6 +129,62 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
])
|
||||
}
|
||||
|
||||
private func setupControls() {
|
||||
shareContainer = UIView()
|
||||
shareContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(sharePressed)))
|
||||
shareContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
topControlsView.addSubview(shareContainer)
|
||||
shareImage = UIImageView(image: UIImage(systemName: "square.and.arrow.up"))
|
||||
shareImage.tintColor = .white
|
||||
shareImage.contentMode = .scaleAspectFit
|
||||
shareImage.translatesAutoresizingMaskIntoConstraints = false
|
||||
shareContainer.addSubview(shareImage)
|
||||
shareButtonTopConstraint = shareImage.topAnchor.constraint(greaterThanOrEqualTo: shareContainer.topAnchor)
|
||||
shareButtonLeadingConstraint = shareImage.leadingAnchor.constraint(greaterThanOrEqualTo: shareContainer.leadingAnchor)
|
||||
NSLayoutConstraint.activate([
|
||||
shareContainer.topAnchor.constraint(equalTo: topControlsView.topAnchor),
|
||||
shareContainer.leadingAnchor.constraint(equalTo: topControlsView.leadingAnchor),
|
||||
shareContainer.bottomAnchor.constraint(equalTo: topControlsView.bottomAnchor),
|
||||
shareContainer.widthAnchor.constraint(greaterThanOrEqualToConstant: 50),
|
||||
shareContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: 50),
|
||||
|
||||
shareImage.centerXAnchor.constraint(equalTo: shareContainer.centerXAnchor),
|
||||
shareImage.centerYAnchor.constraint(equalTo: shareContainer.centerYAnchor),
|
||||
shareButtonTopConstraint,
|
||||
shareButtonLeadingConstraint,
|
||||
|
||||
shareImage.widthAnchor.constraint(equalToConstant: 24),
|
||||
shareImage.heightAnchor.constraint(equalToConstant: 24),
|
||||
])
|
||||
|
||||
let closeContainer = UIView()
|
||||
closeContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(closeButtonPressed)))
|
||||
closeContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
topControlsView.addSubview(closeContainer)
|
||||
let closeImage = UIImageView(image: UIImage(systemName: "xmark"))
|
||||
closeImage.tintColor = .white
|
||||
closeImage.contentMode = .scaleAspectFit
|
||||
closeImage.translatesAutoresizingMaskIntoConstraints = false
|
||||
closeContainer.addSubview(closeImage)
|
||||
closeButtonTopConstraint = closeImage.topAnchor.constraint(greaterThanOrEqualTo: closeContainer.topAnchor)
|
||||
closeButtonTrailingConstraint = closeContainer.trailingAnchor.constraint(greaterThanOrEqualTo: closeImage.trailingAnchor)
|
||||
NSLayoutConstraint.activate([
|
||||
closeContainer.topAnchor.constraint(equalTo: topControlsView.topAnchor),
|
||||
closeContainer.trailingAnchor.constraint(equalTo: topControlsView.trailingAnchor),
|
||||
closeContainer.bottomAnchor.constraint(equalTo: closeContainer.bottomAnchor),
|
||||
closeContainer.widthAnchor.constraint(greaterThanOrEqualToConstant: 50),
|
||||
closeContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: 50),
|
||||
|
||||
closeImage.centerXAnchor.constraint(equalTo: closeContainer.centerXAnchor),
|
||||
closeImage.centerYAnchor.constraint(equalTo: closeContainer.centerYAnchor),
|
||||
closeButtonTopConstraint,
|
||||
closeButtonTrailingConstraint,
|
||||
|
||||
closeImage.widthAnchor.constraint(equalToConstant: 24),
|
||||
closeImage.heightAnchor.constraint(equalToConstant: 24),
|
||||
])
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
|
@ -152,7 +215,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
// since the corner radius didn't change
|
||||
let notchWidth: CGFloat = 210
|
||||
let earWidth = (view.bounds.width - notchWidth) / 2
|
||||
let offset = (earWidth - shareButton.bounds.width) / 2
|
||||
let offset = (earWidth - shareImage.bounds.width) / 2
|
||||
shareButtonLeadingConstraint.constant = offset
|
||||
closeButtonTrailingConstraint.constant = offset
|
||||
} else if pillDeviceTopInsets.contains(view.safeAreaInsets.top) {
|
||||
|
@ -271,7 +334,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
|
||||
@IBAction func sharePressed(_ sender: Any) {
|
||||
let activityVC = UIActivityViewController(activityItems: contentView.activityItemsForSharing, applicationActivities: nil)
|
||||
activityVC.popoverPresentationController?.sourceView = shareButton
|
||||
activityVC.popoverPresentationController?.sourceView = shareImage
|
||||
present(activityVC, animated: true)
|
||||
}
|
||||
|
||||
|
|
|
@ -11,15 +11,8 @@
|
|||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="LargeImageViewController" customModule="Tusker" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="bottomControlsView" destination="rPa-Zu-T6g" id="Rgz-AQ-9nt"/>
|
||||
<outlet property="closeButton" destination="pnA-ne-k0v" id="RPP-cB-9ap"/>
|
||||
<outlet property="closeButtonTopConstraint" destination="ImD-2H-0XK" id="DUe-b1-a2N"/>
|
||||
<outlet property="closeButtonTrailingConstraint" destination="JFe-ig-3Ic" id="cWO-Rr-y3F"/>
|
||||
<outlet property="descriptionLabel" destination="eo5-fc-RV8" id="vrW-RJ-y5k"/>
|
||||
<outlet property="scrollView" destination="Skj-xq-AgQ" id="TFb-zF-m1b"/>
|
||||
<outlet property="shareButton" destination="vhp-0u-Q0S" id="JZS-K9-4w9"/>
|
||||
<outlet property="shareButtonLeadingConstraint" destination="MJx-2r-p0k" id="Dn5-Eg-Pid"/>
|
||||
<outlet property="shareButtonTopConstraint" destination="sgG-dC-xXP" id="Rjp-od-00F"/>
|
||||
<outlet property="topControlsHeightConstraint" destination="6XT-D6-8FS" id="mTB-LF-50H"/>
|
||||
<outlet property="topControlsView" destination="kHo-B9-R7a" id="8sJ-xQ-7ix"/>
|
||||
<outlet property="view" destination="BJw-5C-9nT" id="1C2-VA-mNf"/>
|
||||
</connections>
|
||||
|
@ -33,45 +26,8 @@
|
|||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<gestureRecognizers/>
|
||||
</scrollView>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="kHo-B9-R7a">
|
||||
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kHo-B9-R7a">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="36"/>
|
||||
<subviews>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="vhp-0u-Q0S">
|
||||
<rect key="frame" x="16" y="16" width="20" height="20"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="20" id="4tF-oL-qXT"/>
|
||||
<constraint firstAttribute="width" constant="20" id="zWx-jJ-dBj"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<state key="normal" image="square.and.arrow.up" catalog="system"/>
|
||||
<connections>
|
||||
<action selector="sharePressed:" destination="-1" eventType="touchUpInside" id="7Oz-zv-m2t"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="pnA-ne-k0v">
|
||||
<rect key="frame" x="339" y="16" width="20" height="20"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="20" id="eg0-hN-rda"/>
|
||||
<constraint firstAttribute="height" constant="20" id="fmA-pI-8WB"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<state key="normal" image="xmark" catalog="system"/>
|
||||
<connections>
|
||||
<action selector="closeButtonPressed:" destination="-1" eventType="touchUpInside" id="7o3-ET-EMo"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" secondItem="pnA-ne-k0v" secondAttribute="height" constant="16" id="6XT-D6-8FS"/>
|
||||
<constraint firstItem="pnA-ne-k0v" firstAttribute="top" secondItem="kHo-B9-R7a" secondAttribute="top" constant="16" id="ImD-2H-0XK"/>
|
||||
<constraint firstAttribute="trailing" secondItem="pnA-ne-k0v" secondAttribute="trailing" constant="16" id="JFe-ig-3Ic"/>
|
||||
<constraint firstItem="vhp-0u-Q0S" firstAttribute="leading" secondItem="kHo-B9-R7a" secondAttribute="leading" constant="16" id="MJx-2r-p0k"/>
|
||||
<constraint firstAttribute="bottom" secondItem="vhp-0u-Q0S" secondAttribute="bottom" id="fi6-JS-UmZ"/>
|
||||
<constraint firstAttribute="bottom" secondItem="pnA-ne-k0v" secondAttribute="bottom" id="hEU-VY-WTd"/>
|
||||
<constraint firstItem="vhp-0u-Q0S" firstAttribute="top" secondItem="kHo-B9-R7a" secondAttribute="top" constant="16" id="sgG-dC-xXP"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rPa-Zu-T6g">
|
||||
<rect key="frame" x="0.0" y="622.5" width="375" height="44.5"/>
|
||||
|
@ -110,8 +66,4 @@
|
|||
<point key="canvasLocation" x="-164" y="476"/>
|
||||
</view>
|
||||
</objects>
|
||||
<resources>
|
||||
<image name="square.and.arrow.up" catalog="system" width="115" height="128"/>
|
||||
<image name="xmark" catalog="system" width="128" height="113"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
|
|
@ -138,9 +138,9 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
|
|||
content = LargeImageGifContentView(gifController: gifController)
|
||||
} else {
|
||||
if let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) {
|
||||
content = LargeImageImageContentView(image: transformedImage, owner: self)
|
||||
content = LargeImageImageContentView(image: transformedImage)
|
||||
} else {
|
||||
content = LargeImageImageContentView(image: image, owner: self)
|
||||
content = LargeImageImageContentView(image: image)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -167,7 +167,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
|
|||
let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: source) {
|
||||
image = grayscale
|
||||
}
|
||||
setContent(LargeImageImageContentView(image: image, owner: self))
|
||||
setContent(LargeImageImageContentView(image: image))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import Combine
|
||||
|
||||
class EditListAccountsViewController: EnhancedTableViewController {
|
||||
|
||||
|
@ -22,6 +23,8 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
|||
var searchResultsController: EditListSearchResultsContainerViewController!
|
||||
var searchController: UISearchController!
|
||||
|
||||
private var listRenamedCancellable: AnyCancellable?
|
||||
|
||||
init(list: List, mastodonController: MastodonController) {
|
||||
self.list = list
|
||||
self.mastodonController = mastodonController
|
||||
|
@ -30,7 +33,13 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
|||
|
||||
listChanged()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: list.id)
|
||||
listRenamedCancellable = mastodonController.$lists
|
||||
.compactMap { $0.first { $0.id == list.id } }
|
||||
.removeDuplicates(by: { $0.title == $1.title })
|
||||
.sink { [unowned self] in
|
||||
self.list = $0
|
||||
self.listChanged()
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
@ -88,12 +97,6 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
|||
title = String(format: NSLocalizedString("Edit %@", comment: "edit list screen title"), list.title)
|
||||
}
|
||||
|
||||
@objc private func listRenamed(_ notification: Foundation.Notification) {
|
||||
let list = notification.userInfo!["list"] as! List
|
||||
self.list = list
|
||||
self.listChanged()
|
||||
}
|
||||
|
||||
func loadAccounts() async {
|
||||
do {
|
||||
let request = List.getAccounts(list)
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import Combine
|
||||
|
||||
class ListTimelineViewController: TimelineViewController {
|
||||
|
||||
|
@ -15,6 +16,8 @@ class ListTimelineViewController: TimelineViewController {
|
|||
|
||||
var presentEditOnAppear = false
|
||||
|
||||
private var listRenamedCancellable: AnyCancellable?
|
||||
|
||||
init(for list: List, mastodonController: MastodonController) {
|
||||
self.list = list
|
||||
|
||||
|
@ -22,7 +25,13 @@ class ListTimelineViewController: TimelineViewController {
|
|||
|
||||
listChanged()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: list.id)
|
||||
listRenamedCancellable = mastodonController.$lists
|
||||
.compactMap { $0.first { $0.id == list.id } }
|
||||
.removeDuplicates(by: { $0.title == $1.title })
|
||||
.sink { [unowned self] in
|
||||
self.list = $0
|
||||
self.listChanged()
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
|
@ -40,6 +49,7 @@ class ListTimelineViewController: TimelineViewController {
|
|||
|
||||
if presentEditOnAppear {
|
||||
presentEdit(animated: animated)
|
||||
presentEditOnAppear = false
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -47,12 +57,6 @@ class ListTimelineViewController: TimelineViewController {
|
|||
title = list.title
|
||||
}
|
||||
|
||||
@objc private func listRenamed(_ notification: Foundation.Notification) {
|
||||
let list = notification.userInfo!["list"] as! List
|
||||
self.list = list
|
||||
self.listChanged()
|
||||
}
|
||||
|
||||
func presentEdit(animated: Bool) {
|
||||
let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController)
|
||||
editListAccountsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed))
|
||||
|
|
|
@ -87,6 +87,16 @@ extension AccountSwitchingContainerViewController {
|
|||
}
|
||||
|
||||
extension AccountSwitchingContainerViewController: TuskerRootViewController {
|
||||
func stateRestorationActivity() -> NSUserActivity? {
|
||||
loadViewIfNeeded()
|
||||
return root.stateRestorationActivity()
|
||||
}
|
||||
|
||||
func restoreActivity(_ activity: NSUserActivity) {
|
||||
loadViewIfNeeded()
|
||||
root.restoreActivity(activity)
|
||||
}
|
||||
|
||||
func presentCompose() {
|
||||
loadViewIfNeeded()
|
||||
root.presentCompose()
|
||||
|
|
|
@ -11,6 +11,14 @@ import Duckable
|
|||
|
||||
@available(iOS 16.0, *)
|
||||
extension DuckableContainerViewController: TuskerRootViewController {
|
||||
func stateRestorationActivity() -> NSUserActivity? {
|
||||
(child as? TuskerRootViewController)?.stateRestorationActivity()
|
||||
}
|
||||
|
||||
func restoreActivity(_ activity: NSUserActivity) {
|
||||
(child as? TuskerRootViewController)?.restoreActivity(activity)
|
||||
}
|
||||
|
||||
func presentCompose() {
|
||||
(child as? TuskerRootViewController)?.presentCompose()
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import Combine
|
||||
|
||||
protocol MainSidebarViewControllerDelegate: AnyObject {
|
||||
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
|
||||
|
@ -28,6 +29,8 @@ class MainSidebarViewController: UIViewController {
|
|||
private var collectionView: UICollectionView!
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
|
||||
private var listsCancellable: AnyCancellable?
|
||||
|
||||
var allItems: [Item] {
|
||||
[
|
||||
.tab(.timelines),
|
||||
|
@ -99,10 +102,11 @@ class MainSidebarViewController: UIViewController {
|
|||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(reloadLists), name: .listsChanged, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
|
||||
listsCancellable = mastodonController.$lists
|
||||
.sink { [unowned self] in self.reloadLists($0) }
|
||||
|
||||
onViewDidLoad?()
|
||||
}
|
||||
|
||||
|
@ -163,14 +167,14 @@ class MainSidebarViewController: UIViewController {
|
|||
snapshot.appendItems([
|
||||
.tab(.compose)
|
||||
], toSection: .compose)
|
||||
if mastodonController.instanceFeatures.instanceType.isMastodon,
|
||||
if mastodonController.instanceFeatures.trends,
|
||||
!Preferences.shared.hideDiscover {
|
||||
snapshot.insertSections([.discover], afterSection: .compose)
|
||||
}
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
applyDiscoverSectionSnapshot()
|
||||
reloadLists()
|
||||
reloadLists(mastodonController.lists)
|
||||
reloadSavedHashtags()
|
||||
reloadSavedInstances()
|
||||
}
|
||||
|
@ -188,7 +192,7 @@ class MainSidebarViewController: UIViewController {
|
|||
}
|
||||
|
||||
private func ownInstanceLoaded(_ instance: Instance) {
|
||||
if mastodonController.instanceFeatures.instanceType.isMastodon {
|
||||
if mastodonController.instanceFeatures.trends {
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
if !snapshot.sectionIdentifiers.contains(.discover) {
|
||||
snapshot.appendSections([.discover])
|
||||
|
@ -203,42 +207,28 @@ class MainSidebarViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
@objc private func reloadLists() {
|
||||
let request = Client.getLists()
|
||||
mastodonController.run(request) { [weak self] (response) in
|
||||
guard let self = self, case let .success(lists, _) = response else { return }
|
||||
|
||||
var exploreSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
|
||||
exploreSnapshot.append([.listsHeader])
|
||||
exploreSnapshot.expand([.listsHeader])
|
||||
exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader)
|
||||
exploreSnapshot.append([.addList], to: .listsHeader)
|
||||
DispatchQueue.main.async {
|
||||
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)
|
||||
}
|
||||
}
|
||||
private func reloadLists(_ lists: [List]) {
|
||||
var exploreSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
|
||||
exploreSnapshot.append([.listsHeader])
|
||||
exploreSnapshot.expand([.listsHeader])
|
||||
exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader)
|
||||
exploreSnapshot.append([.addList], to: .listsHeader)
|
||||
var selectedItem: Item?
|
||||
if let selectedIndexPath = collectionView.indexPathsForSelectedItems?.first,
|
||||
let item = dataSource.itemIdentifier(for: selectedIndexPath) {
|
||||
if case .list(let list) = item,
|
||||
let newList = lists.first(where: { $0.id == list.id }) {
|
||||
selectedItem = .list(newList)
|
||||
} else {
|
||||
selectedItem = item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func listRenamed(_ notification: Foundation.Notification) {
|
||||
let list = notification.userInfo!["list"] as! List
|
||||
var snapshot = dataSource.snapshot()
|
||||
let existing = snapshot.itemIdentifiers(inSection: .lists).first(where: {
|
||||
if case .list(let existingList) = $0, existingList.id == list.id {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
self.dataSource.apply(exploreSnapshot, to: .lists) {
|
||||
if let selectedItem,
|
||||
let indexPath = self.dataSource.indexPath(for: selectedItem) {
|
||||
self.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredVertically)
|
||||
}
|
||||
})
|
||||
if let existing {
|
||||
snapshot.insertItems([.list(list)], afterItem: existing)
|
||||
snapshot.deleteItems([existing])
|
||||
dataSource.apply(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -83,6 +83,14 @@ class MainSplitViewController: UISplitViewController {
|
|||
secondaryNavController.viewControllers = getOrCreateNavigationStack(item: item)
|
||||
}
|
||||
|
||||
func navigationStackFor(item: MainSidebarViewController.Item) -> [UIViewController]? {
|
||||
if sidebar.selectedItem == item {
|
||||
return secondaryNavController.viewControllers
|
||||
} else {
|
||||
return navigationStacks[item]
|
||||
}
|
||||
}
|
||||
|
||||
func getOrCreateNavigationStack(item: MainSidebarViewController.Item) -> [UIViewController] {
|
||||
if let existing = navigationStacks[item], existing.count > 0 {
|
||||
return existing
|
||||
|
@ -378,6 +386,36 @@ extension MainSplitViewController: TuskerNavigationDelegate {
|
|||
}
|
||||
|
||||
extension MainSplitViewController: TuskerRootViewController {
|
||||
func stateRestorationActivity() -> NSUserActivity? {
|
||||
if traitCollection.horizontalSizeClass == .compact {
|
||||
return tabBarViewController.stateRestorationActivity()
|
||||
} else {
|
||||
if let timelinePages = navigationStackFor(item: .tab(.timelines))?.first as? TimelinesPageViewController {
|
||||
let timeline = timelinePages.pageControllers[timelinePages.currentIndex] as! TimelineViewController
|
||||
return timeline.stateRestorationActivity()
|
||||
} else {
|
||||
stateRestorationLogger.fault("MainSplitViewController: Unable to create state restoration activity")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restoreActivity(_ activity: NSUserActivity) {
|
||||
if traitCollection.horizontalSizeClass == .compact {
|
||||
tabBarViewController.restoreActivity(activity)
|
||||
} else {
|
||||
if activity.activityType == UserActivityType.showTimeline.rawValue {
|
||||
guard let timelinePages = navigationStackFor(item: .tab(.timelines))?.first as? TimelinesPageViewController else {
|
||||
stateRestorationLogger.fault("MainSplitViewController: Unable to restore timeline activity, couldn't find VC")
|
||||
return
|
||||
}
|
||||
timelinePages.restoreActivity(activity)
|
||||
} else {
|
||||
stateRestorationLogger.fault("MainSplitViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func presentCompose() {
|
||||
self.compose()
|
||||
}
|
||||
|
|
|
@ -233,6 +233,30 @@ extension MainTabBarViewController: TuskerNavigationDelegate {
|
|||
}
|
||||
|
||||
extension MainTabBarViewController: TuskerRootViewController {
|
||||
func stateRestorationActivity() -> NSUserActivity? {
|
||||
let nav = viewController(for: .timelines) as! UINavigationController
|
||||
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController,
|
||||
let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController else {
|
||||
stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration actiivty, couldn't find timeline/page VC")
|
||||
return nil
|
||||
}
|
||||
return timelineVC.stateRestorationActivity()
|
||||
}
|
||||
|
||||
func restoreActivity(_ activity: NSUserActivity) {
|
||||
if activity.activityType == UserActivityType.showTimeline.rawValue {
|
||||
let nav = viewController(for: .timelines) as! UINavigationController
|
||||
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController,
|
||||
let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController else {
|
||||
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore timeline activity, couldn't find VC")
|
||||
return
|
||||
}
|
||||
timelineVC.restoreActivity(activity)
|
||||
} else {
|
||||
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func presentCompose() {
|
||||
compose()
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@
|
|||
import UIKit
|
||||
|
||||
protocol TuskerRootViewController: UIViewController, StatusBarTappableViewController {
|
||||
func stateRestorationActivity() -> NSUserActivity?
|
||||
func restoreActivity(_ activity: NSUserActivity)
|
||||
func presentCompose()
|
||||
func select(tab: MainTabBarViewController.Tab)
|
||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?
|
||||
|
|
|
@ -23,7 +23,7 @@ struct AdvancedPrefsView : View {
|
|||
}
|
||||
|
||||
var formattingFooter: some View {
|
||||
var s: AttributedString = "This option is only supported with Pleroma and some compatible Mastodon instances (such as Glitch or Hometown).\n"
|
||||
var s: AttributedString = "This option is only supported with Pleroma and some compatible Mastodon instances (such as Glitch).\n"
|
||||
if let account = LocalData.shared.getMostRecentAccount() {
|
||||
let mastodonController = MastodonController.getForAccount(account)
|
||||
// shouldn't need to load the instance here, because loading it is kicked off my the scene delegate
|
||||
|
|
|
@ -17,6 +17,7 @@ struct ComposingPrefsView: View {
|
|||
visibilitySection
|
||||
composingSection
|
||||
replyingSection
|
||||
writingSection
|
||||
}
|
||||
.listStyle(InsetGroupedListStyle())
|
||||
.navigationBarTitle("Composing")
|
||||
|
@ -76,6 +77,14 @@ struct ComposingPrefsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
var writingSection: some View {
|
||||
Section {
|
||||
Toggle(isOn: $preferences.useTwitterKeyboard) {
|
||||
Text("Show @ and # on Keyboard")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct ComposingPrefsView_Previews: PreviewProvider {
|
||||
|
|
|
@ -61,6 +61,13 @@ class ProfileHeaderCollectionViewCell: UICollectionViewCell {
|
|||
}
|
||||
}
|
||||
|
||||
// overrides an internal method
|
||||
// when the super impl is used, preferredLayoutAttributesFitting(_:) isn't called while the view is offscreen (i.e., window == nil)
|
||||
// and so the collection view imposes a height of 44pts which breaks the layout
|
||||
@objc func _preferredLayoutAttributesFittingAttributes(_ attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
|
||||
return preferredLayoutAttributesFitting(attributes)
|
||||
}
|
||||
|
||||
enum State {
|
||||
case unloaded
|
||||
case placeholder(heightConstraint: NSLayoutConstraint)
|
||||
|
|
|
@ -25,8 +25,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
|||
private var older: RequestRange?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var collectionView: UICollectionView {
|
||||
view as! UICollectionView
|
||||
var collectionView: UICollectionView! {
|
||||
view as? UICollectionView
|
||||
}
|
||||
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
private(set) var headerCell: ProfileHeaderCollectionViewCell?
|
||||
|
@ -157,7 +157,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
|||
}
|
||||
|
||||
Task {
|
||||
if case .notLoadedInitial = await controller.state {
|
||||
if case .notLoadedInitial = controller.state {
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,163 +0,0 @@
|
|||
//
|
||||
// StatusActionAccountListTableViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 9/5/19.
|
||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class StatusActionAccountListTableViewController: EnhancedTableViewController {
|
||||
|
||||
private let statusCell = "statusCell"
|
||||
private let accountCell = "accountCell"
|
||||
|
||||
weak var mastodonController: MastodonController!
|
||||
|
||||
let actionType: ActionType
|
||||
let statusID: String
|
||||
var statusState: StatusState
|
||||
var accountIDs: [String]? {
|
||||
didSet {
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
/// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate.
|
||||
var showInacurateCountWarning = false
|
||||
|
||||
/**
|
||||
Creates a new view controller showing the accounts that performed the given action on the given status.
|
||||
|
||||
- Parameter actionType The action that this VC is for.
|
||||
- Parameter statusID The ID of the status to show.
|
||||
- Parameter accountIDs The accounts that will be shown. If `nil` is passed, a request will be performed to load the accounts.
|
||||
- Parameter mastodonController The `MastodonController` instance this view controller uses.
|
||||
*/
|
||||
init(actionType: ActionType, statusID: String, statusState: StatusState, accountIDs: [String]?, mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
self.actionType = actionType
|
||||
self.statusID = statusID
|
||||
self.statusState = statusState
|
||||
self.accountIDs = accountIDs
|
||||
|
||||
super.init(style: .grouped)
|
||||
|
||||
dragEnabled = true
|
||||
|
||||
switch actionType {
|
||||
case .favorite:
|
||||
title = NSLocalizedString("Favorited By", comment: "status favorited by accounts list title")
|
||||
case .reblog:
|
||||
title = NSLocalizedString("Reblogged By", comment: "status reblogged by accounts list title")
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell)
|
||||
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell)
|
||||
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 66 // height of account cell, which will be the most common
|
||||
|
||||
tableView.alwaysBounceVertical = true
|
||||
|
||||
tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: CGFloat.leastNormalMagnitude))
|
||||
|
||||
if accountIDs == nil {
|
||||
// account IDs haven't been set, so perform a request to load them
|
||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||
fatalError("Missing cached status \(statusID)")
|
||||
}
|
||||
|
||||
tableView.tableFooterView = UIActivityIndicatorView(style: .large)
|
||||
|
||||
let request = actionType == .favorite ? Status.getFavourites(status.id) : Status.getReblogs(status.id)
|
||||
mastodonController.run(request) { (response) in
|
||||
guard case let .success(accounts, _) = response else { fatalError() }
|
||||
self.mastodonController.persistentContainer.addAll(accounts: accounts) {
|
||||
DispatchQueue.main.async {
|
||||
self.accountIDs = accounts.map { $0.id }
|
||||
self.tableView.tableFooterView = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Table view data source
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return 2
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
switch section {
|
||||
case 0: // status
|
||||
return 1
|
||||
case 1: // accounts
|
||||
if let accountIDs = accountIDs {
|
||||
return accountIDs.count
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
default:
|
||||
fatalError("Invalid section \(section)")
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
switch indexPath.section {
|
||||
case 0:
|
||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: statusCell, for: indexPath) as? TimelineStatusTableViewCell else { fatalError() }
|
||||
cell.delegate = self
|
||||
cell.updateUI(statusID: statusID, state: statusState)
|
||||
return cell
|
||||
case 1:
|
||||
guard let accountIDs = accountIDs,
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: accountCell, for: indexPath) as? AccountTableViewCell else { fatalError() }
|
||||
cell.delegate = self
|
||||
cell.updateUI(accountID: accountIDs[indexPath.row])
|
||||
return cell
|
||||
default:
|
||||
fatalError("Invalid section \(indexPath.section)")
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||
guard section == 1, showInacurateCountWarning else { return nil }
|
||||
return NSLocalizedString("Favorite and reblog counts for posts originating from instances other than your own may not be accurate.", comment: "shown on lists of status total actions")
|
||||
}
|
||||
|
||||
enum ActionType {
|
||||
case favorite, reblog
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension StatusActionAccountListTableViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
}
|
||||
|
||||
extension StatusActionAccountListTableViewController: ToastableViewController {
|
||||
}
|
||||
|
||||
extension StatusActionAccountListTableViewController: MenuActionProvider {
|
||||
}
|
||||
|
||||
extension StatusActionAccountListTableViewController: StatusTableViewCellDelegate {
|
||||
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
|
||||
// causes the table view to recalculate the cell heights
|
||||
tableView.beginUpdates()
|
||||
tableView.endUpdates()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,266 @@
|
|||
//
|
||||
// StatusActionAccountListViewController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 9/5/19.
|
||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class StatusActionAccountListViewController: UIViewController {
|
||||
|
||||
private let mastodonController: MastodonController
|
||||
private let actionType: ActionType
|
||||
private let statusID: String
|
||||
private let statusState: StatusState
|
||||
private var accountIDs: [String]?
|
||||
|
||||
/// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate.
|
||||
var showInacurateCountWarning = false
|
||||
|
||||
private var collectionView: UICollectionView {
|
||||
view as! UICollectionView
|
||||
}
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
|
||||
/**
|
||||
Creates a new view controller showing the accounts that performed the given action on the given status.
|
||||
|
||||
- Parameter actionType The action that this VC is for.
|
||||
- Parameter statusID The ID of the status to show.
|
||||
- Parameter accountIDs The accounts that will be shown. If `nil` is passed, a request will be performed to load the accounts.
|
||||
- Parameter mastodonController The `MastodonController` instance this view controller uses.
|
||||
*/
|
||||
init(actionType: ActionType, statusID: String, statusState: StatusState, accountIDs: [String]?, mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
self.actionType = actionType
|
||||
self.statusID = statusID
|
||||
self.statusState = statusState
|
||||
self.accountIDs = accountIDs
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
switch actionType {
|
||||
case .favorite:
|
||||
title = NSLocalizedString("Favorited By", comment: "status favorited by accounts list title")
|
||||
case .reblog:
|
||||
title = NSLocalizedString("Reblogged By", comment: "status reblogged by accounts list title")
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
|
||||
switch dataSource.sectionIdentifier(for: sectionIndex)! {
|
||||
case .status:
|
||||
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||
config.footerMode = self.showInacurateCountWarning ? .supplementary : .none
|
||||
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
|
||||
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
|
||||
}
|
||||
config.trailingSwipeActionsConfigurationProvider = { [unowned self] in
|
||||
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
|
||||
}
|
||||
return NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
|
||||
case .accounts:
|
||||
return NSCollectionLayoutSection.list(using: .init(appearance: .grouped), layoutEnvironment: environment)
|
||||
}
|
||||
}
|
||||
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collectionView.delegate = self
|
||||
collectionView.dragDelegate = self
|
||||
dataSource = createDataSource()
|
||||
}
|
||||
|
||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, Void> { [unowned self] cell, indexPath, _ in
|
||||
cell.delegate = self
|
||||
cell.updateUI(statusID: self.statusID, state: self.statusState)
|
||||
}
|
||||
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, item in
|
||||
cell.delegate = self
|
||||
cell.updateUI(accountID: item)
|
||||
}
|
||||
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
|
||||
switch itemIdentifier {
|
||||
case .status:
|
||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: ())
|
||||
case .account(let id):
|
||||
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id)
|
||||
}
|
||||
}
|
||||
let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionFooter) { (headerView, collectionView, indexPath) in
|
||||
var config = headerView.defaultContentConfiguration()
|
||||
config.text = NSLocalizedString("Favorite and reblog counts for posts originating from instances other than your own may not be accurate.", comment: "shown on lists of status total actions")
|
||||
headerView.contentConfiguration = config
|
||||
}
|
||||
dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in
|
||||
return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderCell, for: indexPath)
|
||||
}
|
||||
return dataSource
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.status, .accounts])
|
||||
snapshot.appendItems([.status], toSection: .status)
|
||||
if let accountIDs {
|
||||
snapshot.appendItems(accountIDs.map { .account($0) }, toSection: .accounts)
|
||||
}
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
if accountIDs == nil {
|
||||
Task {
|
||||
await loadAccounts()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAccounts() async {
|
||||
let request: Request<[Account]>
|
||||
switch actionType {
|
||||
case .favorite:
|
||||
request = Status.getFavourites(statusID)
|
||||
case .reblog:
|
||||
request = Status.getReblogs(statusID)
|
||||
}
|
||||
do {
|
||||
// TODO: pagination
|
||||
let (accounts, _) = try await mastodonController.run(request)
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
mastodonController.persistentContainer.addAll(accounts: accounts) {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
accountIDs = accounts.map(\.id)
|
||||
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.appendItems(accounts.map { .account($0.id) }, toSection: .accounts)
|
||||
dataSource.apply(snapshot, animatingDifferences: true) {}
|
||||
|
||||
} catch {
|
||||
let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { toast in
|
||||
toast.dismissToast(animated: true)
|
||||
await self.loadAccounts()
|
||||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusActionAccountListViewController {
|
||||
enum ActionType {
|
||||
case favorite, reblog
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusActionAccountListViewController {
|
||||
enum Section {
|
||||
case status
|
||||
case accounts
|
||||
}
|
||||
enum Item: Hashable {
|
||||
case status
|
||||
case account(String)
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusActionAccountListViewController: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
switch dataSource.itemIdentifier(for: indexPath) {
|
||||
case nil:
|
||||
return
|
||||
case .status:
|
||||
selected(status: statusID, state: statusState.copy())
|
||||
case .account(let id):
|
||||
selected(account: id)
|
||||
}
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||||
let cell = collectionView.cellForItem(at: indexPath) else {
|
||||
return nil
|
||||
}
|
||||
switch item {
|
||||
case .status:
|
||||
return (cell as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration()
|
||||
case .account(let id):
|
||||
return UIContextMenuConfiguration {
|
||||
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
|
||||
} actionProvider: { _ in
|
||||
UIMenu(children: self.actionsForProfile(accountID: id, sourceView: cell))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusActionAccountListViewController: UICollectionViewDragDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
|
||||
guard let currentAccountID = mastodonController.accountInfo?.id,
|
||||
let item = dataSource.itemIdentifier(for: indexPath) else {
|
||||
return []
|
||||
}
|
||||
let provider: NSItemProvider
|
||||
switch item {
|
||||
case .status:
|
||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||
return []
|
||||
}
|
||||
provider = NSItemProvider(object: status.url! as NSURL)
|
||||
let activity = UserActivityManager.showConversationActivity(mainStatusID: statusID, accountID: currentAccountID)
|
||||
activity.displaysAuxiliaryScene = true
|
||||
provider.registerObject(activity, visibility: .all)
|
||||
case .account(let id):
|
||||
guard let account = mastodonController.persistentContainer.account(for: id) else {
|
||||
return []
|
||||
}
|
||||
provider = NSItemProvider(object: account.url as NSURL)
|
||||
let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
|
||||
activity.displaysAuxiliaryScene = true
|
||||
provider.registerObject(activity, visibility: .all)
|
||||
}
|
||||
return [UIDragItem(itemProvider: provider)]
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusActionAccountListViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
}
|
||||
|
||||
extension StatusActionAccountListViewController: MenuActionProvider {
|
||||
}
|
||||
|
||||
extension StatusActionAccountListViewController: StatusCollectionViewCellDelegate {
|
||||
func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) {
|
||||
if let indexPath = collectionView.indexPath(for: cell) {
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!])
|
||||
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension StatusActionAccountListViewController: StatusBarTappableViewController {
|
||||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
||||
collectionView.scrollToTop()
|
||||
return .stop
|
||||
}
|
||||
}
|
|
@ -0,0 +1,165 @@
|
|||
//
|
||||
// TimelineGapCollectionViewCell.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 11/16/22.
|
||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class TimelineGapCollectionViewCell: UICollectionViewCell {
|
||||
|
||||
private(set) var direction = TimelineGapDirection.above
|
||||
|
||||
private let indicator = UIActivityIndicatorView(style: .medium)
|
||||
private let chevronView = AnimatingChevronView()
|
||||
|
||||
override var isHighlighted: Bool {
|
||||
didSet {
|
||||
backgroundColor = isHighlighted ? .systemFill : .systemGroupedBackground
|
||||
}
|
||||
}
|
||||
|
||||
var showsIndicator: Bool = false {
|
||||
didSet {
|
||||
if showsIndicator {
|
||||
indicator.isHidden = false
|
||||
indicator.startAnimating()
|
||||
} else {
|
||||
indicator.isHidden = true
|
||||
indicator.stopAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
backgroundColor = .systemGroupedBackground
|
||||
|
||||
indicator.isHidden = true
|
||||
indicator.color = .tintColor
|
||||
|
||||
let label = UILabel()
|
||||
label.text = "Load more"
|
||||
label.font = .preferredFont(forTextStyle: .headline)
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
label.textColor = .tintColor
|
||||
|
||||
chevronView.update(direction: .above)
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [
|
||||
label,
|
||||
chevronView,
|
||||
])
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = 8
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(stack)
|
||||
|
||||
indicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(indicator)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
stack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
|
||||
stack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
indicator.trailingAnchor.constraint(equalTo: stack.leadingAnchor, constant: -8),
|
||||
indicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
contentView.heightAnchor.constraint(equalToConstant: 44),
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func didMoveToSuperview() {
|
||||
super.didMoveToSuperview()
|
||||
update()
|
||||
}
|
||||
|
||||
func update() {
|
||||
guard let superview = superview as? UICollectionView else {
|
||||
return
|
||||
}
|
||||
let yInParent = frame.minY - superview.contentOffset.y
|
||||
let centerInParent = yInParent + bounds.height / 2
|
||||
let centerOfParent = superview.frame.height / 2
|
||||
|
||||
let newDirection: TimelineGapDirection
|
||||
if centerInParent > centerOfParent {
|
||||
newDirection = .above
|
||||
} else {
|
||||
newDirection = .below
|
||||
}
|
||||
|
||||
if newDirection != direction {
|
||||
direction = newDirection
|
||||
chevronView.update(direction: newDirection)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private class AnimatingChevronView: UIView {
|
||||
|
||||
override class var layerClass: AnyClass { CAShapeLayer.self }
|
||||
var shapeLayer: CAShapeLayer { layer as! CAShapeLayer }
|
||||
|
||||
override var intrinsicContentSize: CGSize { CGSize(width: 20, height: 25) }
|
||||
|
||||
var animator: UIViewPropertyAnimator?
|
||||
|
||||
let upPath: CGPath = {
|
||||
let path = UIBezierPath()
|
||||
let width: CGFloat = 20
|
||||
let height: CGFloat = 25
|
||||
path.move(to: CGPoint(x: 0, y: height / 2))
|
||||
path.addLine(to: CGPoint(x: width / 2, y: height / 5))
|
||||
path.addLine(to: CGPoint(x: width, y: height / 2))
|
||||
return path.cgPath
|
||||
}()
|
||||
let downPath: CGPath = {
|
||||
let path = UIBezierPath()
|
||||
let width: CGFloat = 20
|
||||
let height: CGFloat = 25
|
||||
path.move(to: CGPoint(x: 0, y: height / 2))
|
||||
path.addLine(to: CGPoint(x: width / 2, y: 4 * height / 5))
|
||||
path.addLine(to: CGPoint(x: width, y: height / 2))
|
||||
return path.cgPath
|
||||
}()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
shapeLayer.fillColor = nil
|
||||
shapeLayer.strokeColor = tintColor.cgColor
|
||||
shapeLayer.lineCap = .round
|
||||
shapeLayer.lineJoin = .round
|
||||
shapeLayer.lineWidth = 3
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
shapeLayer.strokeColor = tintColor.cgColor
|
||||
}
|
||||
|
||||
func update(direction: TimelineGapDirection) {
|
||||
if animator?.isRunning == true {
|
||||
animator!.stopAnimation(true)
|
||||
}
|
||||
animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||
if direction == .below {
|
||||
self.shapeLayer.path = self.upPath
|
||||
} else {
|
||||
self.shapeLayer.path = self.downPath
|
||||
}
|
||||
}
|
||||
animator!.startAnimation()
|
||||
}
|
||||
|
||||
}
|
|
@ -16,16 +16,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
|
||||
private(set) var controller: TimelineLikeController<TimelineItem>!
|
||||
let confirmLoadMore = PassthroughSubject<Void, Never>()
|
||||
private var newer: RequestRange?
|
||||
private var older: RequestRange?
|
||||
// stored separately because i don't want to query the snapshot every time the user scrolls
|
||||
private var isShowingTimelineDescription = false
|
||||
|
||||
var collectionView: UICollectionView {
|
||||
view as! UICollectionView
|
||||
}
|
||||
private(set) var collectionView: UICollectionView!
|
||||
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
|
||||
private var contentOffsetObservation: NSKeyValueObservation?
|
||||
private var activityToRestore: NSUserActivity?
|
||||
|
||||
init(for timeline: Timeline, mastodonController: MastodonController!) {
|
||||
self.timeline = timeline
|
||||
self.mastodonController = mastodonController
|
||||
|
@ -42,7 +41,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
var config = UICollectionLayoutListConfiguration(appearance: .plain)
|
||||
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
|
||||
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
|
||||
|
@ -58,17 +59,24 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
if item.hideSeparators {
|
||||
config.topSeparatorVisibility = .hidden
|
||||
config.bottomSeparatorVisibility = .hidden
|
||||
}
|
||||
if case .status(_, _) = item {
|
||||
} else {
|
||||
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||
}
|
||||
return config
|
||||
}
|
||||
let layout = UICollectionViewCompositionalLayout.list(using: config)
|
||||
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collectionView.delegate = self
|
||||
collectionView.dragDelegate = self
|
||||
collectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(collectionView)
|
||||
NSLayoutConstraint.activate([
|
||||
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
registerTimelineLikeCells()
|
||||
collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription")
|
||||
|
@ -79,10 +87,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
collectionView.refreshControl = UIRefreshControl()
|
||||
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
|
||||
#endif
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
contentOffsetObservation = collectionView.observe(\.contentOffset) { [weak self] _, _ in
|
||||
if let indexPath = self?.dataSource.indexPath(for: .gap),
|
||||
let cell = self?.collectionView.cellForItem(at: indexPath) as? TimelineGapCollectionViewCell {
|
||||
cell.update()
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil)
|
||||
}
|
||||
|
||||
// separate method because InstanceTimelineViewController needs to be able to customize it
|
||||
|
@ -95,6 +108,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
|
||||
self.configureStatusCell(cell, id: item.0, state: item.1)
|
||||
}
|
||||
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { cell, indexPath, _ in
|
||||
cell.showsIndicator = false
|
||||
}
|
||||
let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
|
||||
guard case .public(let local) = timeline else {
|
||||
fatalError()
|
||||
|
@ -109,6 +125,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
switch itemIdentifier {
|
||||
case .status(id: let id, state: let state):
|
||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
|
||||
case .gap:
|
||||
return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ())
|
||||
case .loadingIndicator:
|
||||
return loadingIndicatorCell(for: indexPath)
|
||||
case .confirmLoadMore:
|
||||
|
@ -139,9 +157,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
collectionView.deselectItem(at: $0, animated: true)
|
||||
}
|
||||
|
||||
Task {
|
||||
if case .notLoadedInitial = await controller.state {
|
||||
await controller.loadInitial()
|
||||
if case .notLoadedInitial = controller.state {
|
||||
if doRestore() {
|
||||
Task {
|
||||
await checkPresent()
|
||||
}
|
||||
} else {
|
||||
Task {
|
||||
await controller.loadInitial()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -159,10 +183,106 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
}
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
func stateRestorationActivity() -> NSUserActivity? {
|
||||
let visible = collectionView.indexPathsForVisibleItems.sorted()
|
||||
let snapshot = dataSource.snapshot()
|
||||
guard let currentAccountID = mastodonController.accountInfo?.id,
|
||||
!visible.isEmpty,
|
||||
let statusesSection = snapshot.sectionIdentifiers.firstIndex(of: .statuses),
|
||||
let firstVisible = visible.first(where: { $0.section == statusesSection }),
|
||||
let lastVisible = visible.last(where: { $0.section == statusesSection }) else {
|
||||
return nil
|
||||
}
|
||||
let allItems = snapshot.itemIdentifiers(inSection: .statuses)
|
||||
|
||||
// pruneOffscreenRows()
|
||||
let startIndex = max(0, firstVisible.row - 20)
|
||||
let endIndex = min(allItems.count - 1, lastVisible.row + 20)
|
||||
|
||||
let firstVisibleItem: Item
|
||||
var items = allItems[startIndex...endIndex]
|
||||
if let gapIndex = items.firstIndex(of: .gap) {
|
||||
// if the gap is above the top visible item, we take everything below the gap
|
||||
// otherwise, we take everything above the gap
|
||||
if gapIndex <= firstVisible.row {
|
||||
items = allItems[(gapIndex + 1)...endIndex]
|
||||
if gapIndex == firstVisible.row {
|
||||
firstVisibleItem = allItems.first!
|
||||
} else {
|
||||
assert(items.indices.contains(firstVisible.row))
|
||||
firstVisibleItem = allItems[firstVisible.row]
|
||||
}
|
||||
} else {
|
||||
items = allItems[startIndex..<gapIndex]
|
||||
firstVisibleItem = allItems[firstVisible.row]
|
||||
}
|
||||
} else {
|
||||
firstVisibleItem = allItems[firstVisible.row]
|
||||
}
|
||||
let ids = items.map {
|
||||
if case .status(id: let id, state: _) = $0 {
|
||||
return id
|
||||
} else {
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
let firstVisibleID: String
|
||||
if case .status(id: let id, state: _) = firstVisibleItem {
|
||||
firstVisibleID = id
|
||||
} else {
|
||||
fatalError()
|
||||
}
|
||||
stateRestorationLogger.debug("TimelineViewController: creating state restoration activity with topID \(firstVisibleID)")
|
||||
|
||||
let activity = UserActivityManager.showTimelineActivity(timeline: timeline, accountID: currentAccountID)!
|
||||
activity.addUserInfoEntries(from: [
|
||||
"statusIDs": ids,
|
||||
"topID": firstVisibleID,
|
||||
])
|
||||
activity.isEligibleForPrediction = false
|
||||
return activity
|
||||
}
|
||||
|
||||
func restoreActivity(_ activity: NSUserActivity) {
|
||||
self.activityToRestore = activity
|
||||
}
|
||||
|
||||
private func doRestore() -> Bool {
|
||||
guard let activity = activityToRestore,
|
||||
let statusIDs = activity.userInfo?["statusIDs"] as? [String] else {
|
||||
stateRestorationLogger.fault("TimelineViewController: activity missing statusIDs")
|
||||
return false
|
||||
}
|
||||
activityToRestore = nil
|
||||
loadViewIfNeeded()
|
||||
controller.restoreInitial {
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.appendSections([.statuses])
|
||||
let items = statusIDs.map { Item.status(id: $0, state: .unknown) }
|
||||
snapshot.appendItems(items, toSection: .statuses)
|
||||
dataSource.apply(snapshot, animatingDifferences: false) {
|
||||
if let topID = activity.userInfo?["topID"] as? String,
|
||||
let index = statusIDs.firstIndex(of: topID),
|
||||
let indexPath = self.dataSource.indexPath(for: items[index]) {
|
||||
// it sometimes takes multiple attempts to convert on the right scroll position
|
||||
// since we're dealing with a bunch of unmeasured cells, so just try a few times in a loop
|
||||
var count = 0
|
||||
while count < 5 {
|
||||
count += 1
|
||||
let origOffset = self.collectionView.contentOffset
|
||||
self.collectionView.layoutIfNeeded()
|
||||
self.collectionView.scrollToItem(at: indexPath, at: .top, animated: false)
|
||||
let newOffset = self.collectionView.contentOffset
|
||||
if abs(origOffset.y - newOffset.y) <= 1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
stateRestorationLogger.fault("TimelineViewController: restored statuses with top ID \(topID)")
|
||||
} else {
|
||||
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find top ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func removeTimelineDescriptionCell() {
|
||||
|
@ -172,35 +292,27 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
isShowingTimelineDescription = false
|
||||
}
|
||||
|
||||
// private func pruneOffscreenRows() {
|
||||
// guard let lastVisibleIndexPath = collectionView.indexPathsForVisibleItems.last else {
|
||||
// return
|
||||
// }
|
||||
// var snapshot = dataSource.snapshot()
|
||||
// guard snapshot.indexOfSection(.statuses) != nil else {
|
||||
// return
|
||||
// }
|
||||
// let items = snapshot.itemIdentifiers(inSection: .statuses)
|
||||
// let pageSize = 20
|
||||
// let numberOfPagesToPrune = (items.count - lastVisibleIndexPath.row - 1) / pageSize
|
||||
// if numberOfPagesToPrune > 0 {
|
||||
// let itemsToRemove = Array(items.suffix(numberOfPagesToPrune * pageSize))
|
||||
// snapshot.deleteItems(itemsToRemove)
|
||||
//
|
||||
// dataSource.apply(snapshot, animatingDifferences: false)
|
||||
//
|
||||
// if case .status(id: let id, state: _) = snapshot.itemIdentifiers(inSection: .statuses).last {
|
||||
// older = .before(id: id, count: nil)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@objc private func sceneWillEnterForeground(_ notification: Foundation.Notification) {
|
||||
guard let scene = notification.object as? UIScene,
|
||||
// view.window is nil when the VC is not on screen
|
||||
view.window?.windowScene == scene else {
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await checkPresent()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func refresh() {
|
||||
Task {
|
||||
if case .notLoadedInitial = await controller.state {
|
||||
if case .notLoadedInitial = controller.state {
|
||||
await controller.loadInitial()
|
||||
} else {
|
||||
await controller.loadNewer()
|
||||
// I'm not sure whether this should move into TimelineLikeController/TimelineLikeCollectionViewController
|
||||
let (_, presentItems) = await (controller.loadNewer(), try? loadInitial())
|
||||
if let presentItems {
|
||||
insertPresentItemsIfNecessary(presentItems)
|
||||
}
|
||||
}
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
collectionView.refreshControl?.endRefreshing()
|
||||
|
@ -208,6 +320,90 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
}
|
||||
}
|
||||
|
||||
private func checkPresent() async {
|
||||
if case .idle = controller.state,
|
||||
let presentItems = try? await loadInitial() {
|
||||
insertPresentItemsIfNecessary(presentItems)
|
||||
}
|
||||
}
|
||||
|
||||
private func insertPresentItemsIfNecessary(_ presentItems: [String]) {
|
||||
var snapshot = dataSource.snapshot()
|
||||
let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
|
||||
if case .status(id: let firstID, state: _) = currentItems.first,
|
||||
// if there's no overlap between presentItems and the existing items in the data source, insert the present items and prompt the user
|
||||
!presentItems.contains(firstID) {
|
||||
|
||||
// remove any existing gap, if there is one
|
||||
if let index = currentItems.lastIndex(of: .gap) {
|
||||
snapshot.deleteItems(Array(currentItems[index...]))
|
||||
}
|
||||
snapshot.insertItems([.gap], beforeItem: currentItems.first!)
|
||||
snapshot.insertItems(presentItems.map { .status(id: $0, state: .unknown) }, beforeItem: .gap)
|
||||
|
||||
var config = ToastConfiguration(title: "Jump to present")
|
||||
config.edge = .top
|
||||
config.systemImageName = "arrow.up"
|
||||
config.dismissAutomaticallyAfter = 4
|
||||
config.action = { [unowned self] toast in
|
||||
toast.dismissToast(animated: true)
|
||||
|
||||
self.dataSource.apply(snapshot, animatingDifferences: true) {
|
||||
// TODO: we can't set prevScrollOffsetBeforeScrollToTop here to allow undoing the scroll-to-top
|
||||
// because that would involve scrolling through unmeasured-cell which fucks up the content offset values.
|
||||
// we probably need a data-source aware implementation of scrollToTop which uses item & offset w/in item
|
||||
// to track the restore position
|
||||
self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: true)
|
||||
}
|
||||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: this only works when items are being inserted ABOVE the item to maintain
|
||||
private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, maintainingBottomRelativeScrollPositionOf itemToMaintain: Item) {
|
||||
// use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back
|
||||
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)!
|
||||
snapshotView.layer.zPosition = 1000
|
||||
snapshotView.frame = view.bounds
|
||||
view.addSubview(snapshotView)
|
||||
|
||||
var firstItemAfterOriginalGapOffsetFromTop: CGFloat = 0
|
||||
if let indexPath = dataSource.indexPath(for: itemToMaintain),
|
||||
let cell = collectionView.cellForItem(at: indexPath) {
|
||||
// subtract top safe area inset b/c scrollToItem at .top aligns the top of the cell to the top of the safe area
|
||||
firstItemAfterOriginalGapOffsetFromTop = cell.convert(.zero, to: view).y - view.safeAreaInsets.top
|
||||
}
|
||||
|
||||
dataSource.apply(snapshot, animatingDifferences: false) {
|
||||
if let indexPathOfItemAfterOriginalGap = self.dataSource.indexPath(for: itemToMaintain) {
|
||||
// scroll up until we've accumulated enough MEASURED height that we can put the
|
||||
// firstItemAfterOriginalGapCell at the top of the screen and then scroll down by
|
||||
// firstItemAfterOriginalGapOffsetFromTop without intruding into unmeasured area
|
||||
var cur = indexPathOfItemAfterOriginalGap
|
||||
var amountScrolledUp: CGFloat = 0
|
||||
while true {
|
||||
if cur.row <= 0 {
|
||||
break
|
||||
}
|
||||
if let cell = self.collectionView.cellForItem(at: indexPathOfItemAfterOriginalGap),
|
||||
cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > firstItemAfterOriginalGapOffsetFromTop {
|
||||
break
|
||||
}
|
||||
cur = IndexPath(row: cur.row - 1, section: cur.section)
|
||||
self.collectionView.scrollToItem(at: cur, at: .top, animated: false)
|
||||
self.collectionView.layoutIfNeeded()
|
||||
let attrs = self.collectionView.layoutAttributesForItem(at: cur)!
|
||||
amountScrolledUp += attrs.size.height
|
||||
}
|
||||
self.collectionView.contentOffset.y += amountScrolledUp
|
||||
self.collectionView.contentOffset.y -= firstItemAfterOriginalGapOffsetFromTop
|
||||
}
|
||||
|
||||
snapshotView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension TimelineViewController {
|
||||
|
@ -222,6 +418,7 @@ extension TimelineViewController {
|
|||
typealias TimelineItem = String // status ID
|
||||
|
||||
case status(id: String, state: StatusState)
|
||||
case gap
|
||||
case loadingIndicator
|
||||
case confirmLoadMore
|
||||
case publicTimelineDescription
|
||||
|
@ -234,6 +431,8 @@ extension TimelineViewController {
|
|||
switch (lhs, rhs) {
|
||||
case let (.status(id: a, state: _), .status(id: b, state: _)):
|
||||
return a == b
|
||||
case (.gap, .gap):
|
||||
return true
|
||||
case (.loadingIndicator, .loadingIndicator):
|
||||
return true
|
||||
case (.confirmLoadMore, .confirmLoadMore):
|
||||
|
@ -250,12 +449,14 @@ extension TimelineViewController {
|
|||
case .status(id: let id, state: _):
|
||||
hasher.combine(0)
|
||||
hasher.combine(id)
|
||||
case .loadingIndicator:
|
||||
case .gap:
|
||||
hasher.combine(1)
|
||||
case .confirmLoadMore:
|
||||
case .loadingIndicator:
|
||||
hasher.combine(2)
|
||||
case .publicTimelineDescription:
|
||||
case .confirmLoadMore:
|
||||
hasher.combine(3)
|
||||
case .publicTimelineDescription:
|
||||
hasher.combine(4)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -270,7 +471,7 @@ extension TimelineViewController {
|
|||
|
||||
var isSelectable: Bool {
|
||||
switch self {
|
||||
case .publicTimelineDescription, .status(id: _, state: _):
|
||||
case .publicTimelineDescription, .gap, .status(id: _, state: _):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
@ -286,50 +487,48 @@ extension TimelineViewController {
|
|||
func loadInitial() async throws -> [TimelineItem] {
|
||||
try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
|
||||
|
||||
guard let mastodonController else {
|
||||
throw Error.noClient
|
||||
}
|
||||
|
||||
let request = Client.getStatuses(timeline: timeline)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
|
||||
if !statuses.isEmpty {
|
||||
newer = .after(id: statuses.first!.id, count: nil)
|
||||
older = .before(id: statuses.last!.id, count: nil)
|
||||
}
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
await withCheckedContinuation { continuation in
|
||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
continuation.resume(returning: statuses.map(\.id))
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
return statuses.map(\.id)
|
||||
}
|
||||
|
||||
func loadNewer() async throws -> [TimelineItem] {
|
||||
guard let newer else {
|
||||
let statusesSection = dataSource.snapshot().indexOfSection(.statuses)!
|
||||
guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: 0, section: statusesSection)) else {
|
||||
throw Error.noNewer
|
||||
}
|
||||
let newer = RequestRange.after(id: id, count: nil)
|
||||
|
||||
let request = Client.getStatuses(timeline: timeline, range: newer)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
|
||||
guard !statuses.isEmpty else {
|
||||
throw Error.allCaughtUp
|
||||
throw TimelineViewController.Error.allCaughtUp
|
||||
}
|
||||
|
||||
self.newer = .after(id: statuses.first!.id, count: nil)
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
await withCheckedContinuation { continuation in
|
||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
continuation.resume(returning: statuses.map(\.id))
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
return statuses.map(\.id)
|
||||
}
|
||||
|
||||
func loadOlder() async throws -> [TimelineItem] {
|
||||
guard let older else {
|
||||
throw Error.noOlder
|
||||
let snapshot = dataSource.snapshot()
|
||||
let statusesSection = snapshot.indexOfSection(.statuses)!
|
||||
guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: snapshot.numberOfItems(inSection: .statuses) - 1, section: statusesSection)) else {
|
||||
throw Error.noNewer
|
||||
}
|
||||
let older = RequestRange.before(id: id, count: nil)
|
||||
|
||||
let request = Client.getStatuses(timeline: timeline, range: older)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
|
@ -338,13 +537,149 @@ extension TimelineViewController {
|
|||
return []
|
||||
}
|
||||
|
||||
self.older = .before(id: statuses.last!.id, count: nil)
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
await withCheckedContinuation { continuation in
|
||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
continuation.resume(returning: statuses.map(\.id))
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
return statuses.map(\.id)
|
||||
}
|
||||
|
||||
func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] {
|
||||
guard let gapIndexPath = dataSource.indexPath(for: .gap) else {
|
||||
throw Error.noGap
|
||||
}
|
||||
let statusItemsCount = collectionView.numberOfItems(inSection: gapIndexPath.section)
|
||||
let range: RequestRange
|
||||
switch direction {
|
||||
case .above:
|
||||
guard gapIndexPath.row > 0,
|
||||
case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row - 1, section: gapIndexPath.section)) else {
|
||||
// not really the right error but w/e
|
||||
throw Error.noGap
|
||||
}
|
||||
range = .before(id: id, count: nil)
|
||||
case .below:
|
||||
guard gapIndexPath.row < statusItemsCount - 1,
|
||||
case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row + 1, section: gapIndexPath.section)) else {
|
||||
throw Error.noGap
|
||||
}
|
||||
range = .after(id: id, count: nil)
|
||||
}
|
||||
|
||||
let request = Client.getStatuses(timeline: timeline, range: range)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
|
||||
guard !statuses.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
// NOTE: closing the gap (if necessary) happens in handleFillGap
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
return statuses.map(\.id)
|
||||
}
|
||||
|
||||
func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async {
|
||||
// TODO: better title, involving direction?
|
||||
let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in
|
||||
toast.dismissToast(animated: true)
|
||||
Task {
|
||||
await self?.controller.fillGap(in: direction)
|
||||
}
|
||||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
}
|
||||
|
||||
func handleFillGap(_ timelineItems: [String], direction: TimelineGapDirection) async {
|
||||
var snapshot = dataSource.snapshot()
|
||||
let addedItems: Bool
|
||||
|
||||
let statusItems = snapshot.itemIdentifiers(inSection: .statuses)
|
||||
let gapIndex = statusItems.firstIndex(of: .gap)!
|
||||
|
||||
switch direction {
|
||||
case .above:
|
||||
// dropFirst to remove .gap item
|
||||
let afterGap = statusItems[gapIndex...].dropFirst().prefix(20)
|
||||
precondition(!afterGap.contains(.gap))
|
||||
|
||||
// if there is any overlap, the first overlapping item will be the first item below the gap
|
||||
var indexOfFirstTimelineItemExistingBelowGap: Int?
|
||||
if case .status(id: let id, state: _) = afterGap.first {
|
||||
indexOfFirstTimelineItemExistingBelowGap = timelineItems.firstIndex(of: id)
|
||||
}
|
||||
|
||||
// the end index of the range of timelineItems that don't yet exist in the data source
|
||||
let endIndex = indexOfFirstTimelineItemExistingBelowGap ?? timelineItems.endIndex
|
||||
let toInsert = timelineItems[..<endIndex].map { Item.status(id: $0, state: .unknown) }
|
||||
if toInsert.isEmpty {
|
||||
addedItems = false
|
||||
} else {
|
||||
snapshot.insertItems(toInsert, beforeItem: .gap)
|
||||
addedItems = true
|
||||
}
|
||||
|
||||
// if there's any overlap between the items we loaded to insert above the gap
|
||||
// and the items that already exist below the gap, we've completely filled the gap
|
||||
if indexOfFirstTimelineItemExistingBelowGap != nil {
|
||||
snapshot.deleteItems([.gap])
|
||||
}
|
||||
|
||||
await apply(snapshot, animatingDifferences: !addedItems)
|
||||
|
||||
case .below:
|
||||
let beforeGap = statusItems[..<gapIndex].suffix(20)
|
||||
precondition(!beforeGap.contains(.gap))
|
||||
|
||||
// if there's any overlap, last overlapping item will be the last item below the gap
|
||||
var indexOfLastTimelineItemExistingAboveGap: Int?
|
||||
if case .status(id: let id, state: _) = beforeGap.last {
|
||||
indexOfLastTimelineItemExistingAboveGap = timelineItems.lastIndex(of: id)
|
||||
}
|
||||
|
||||
// the start index of the reange of timeline items that don't yet exist in the data source
|
||||
let startIndex: Int
|
||||
if let indexOfLastTimelineItemExistingAboveGap {
|
||||
// index(after:) because the beginning of the range is inclusive, but we don't want the item at indexOfLastTimelineItemExistingAboveGap
|
||||
startIndex = timelineItems.index(after: indexOfLastTimelineItemExistingAboveGap)
|
||||
} else {
|
||||
startIndex = timelineItems.startIndex
|
||||
}
|
||||
let toInsert = timelineItems[startIndex...].map { Item.status(id: $0, state: .unknown) }
|
||||
if toInsert.isEmpty {
|
||||
addedItems = false
|
||||
} else {
|
||||
snapshot.insertItems(toInsert, afterItem: .gap)
|
||||
addedItems = true
|
||||
}
|
||||
|
||||
// if there's any overlap between the items we loaded to insert below the gap
|
||||
// and the items that already exist above the gap, we've completely filled the gap
|
||||
if indexOfLastTimelineItemExistingAboveGap != nil {
|
||||
snapshot.deleteItems([.gap])
|
||||
}
|
||||
|
||||
if addedItems {
|
||||
let firstItemAfterOriginalGap = statusItems[gapIndex + 1]
|
||||
applySnapshot(snapshot, maintainingBottomRelativeScrollPositionOf: firstItemAfterOriginalGap)
|
||||
} else {
|
||||
dataSource.apply(snapshot, animatingDifferences: true) {}
|
||||
}
|
||||
}
|
||||
|
||||
// if we didn't add any items, that implies the gap was removed, and we want to to make clear what's happening
|
||||
if !addedItems {
|
||||
var config = ToastConfiguration(title: "There's nothing in between!")
|
||||
config.dismissAutomaticallyAfter = 2
|
||||
showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
enum Error: TimelineLikeCollectionViewError {
|
||||
|
@ -352,6 +687,7 @@ extension TimelineViewController {
|
|||
case noNewer
|
||||
case noOlder
|
||||
case allCaughtUp
|
||||
case noGap
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -384,6 +720,13 @@ extension TimelineViewController: UICollectionViewDelegate {
|
|||
let status = mastodonController.persistentContainer.status(for: id)!
|
||||
// if the status in the timeline is a reblog, show the status that it is a reblog of
|
||||
selected(status: status.reblog?.id ?? id, state: state.copy())
|
||||
case .gap:
|
||||
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
|
||||
cell.showsIndicator = true
|
||||
Task {
|
||||
await controller.fillGap(in: cell.direction)
|
||||
cell.showsIndicator = false
|
||||
}
|
||||
case .loadingIndicator, .confirmLoadMore:
|
||||
fatalError("unreachable")
|
||||
}
|
||||
|
|
|
@ -46,4 +46,22 @@ class TimelinesPageViewController: SegmentedPageViewController {
|
|||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func restoreActivity(_ activity: NSUserActivity) {
|
||||
guard let timeline = UserActivityManager.getTimeline(from: activity) else {
|
||||
return
|
||||
}
|
||||
switch timeline {
|
||||
case .home:
|
||||
selectPage(at: 0, animated: false)
|
||||
case .public(local: false):
|
||||
selectPage(at: 1, animated: false)
|
||||
case .public(local: true):
|
||||
selectPage(at: 2, animated: false)
|
||||
default:
|
||||
return
|
||||
}
|
||||
let timelineVC = pageControllers[currentIndex] as! TimelineViewController
|
||||
timelineVC.restoreActivity(activity)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -357,6 +357,7 @@ class CustomAlertActionButton: UIControl {
|
|||
titleView = UIStackView()
|
||||
titleView.axis = .horizontal
|
||||
titleView.spacing = 4
|
||||
titleView.alignment = .center
|
||||
|
||||
if let title = action.title {
|
||||
let label = UILabel()
|
||||
|
|
|
@ -66,8 +66,22 @@ extension MenuActionProvider {
|
|||
]
|
||||
var suppressSection: [UIMenuElement] = []
|
||||
|
||||
if accountID != loggedInAccountID {
|
||||
if let ownAccount = mastodonController.account,
|
||||
accountID != ownAccount.id {
|
||||
actionsSection.append(relationshipAction(accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.followAction(for: $0, mastodonController: $1) }))
|
||||
actionsSection.append(UIDeferredMenuElement.uncached({ elementHandler in
|
||||
let listActions = mastodonController.lists.map { list in
|
||||
UIAction(title: list.title, image: UIImage(systemName: "plus")) { [unowned self] _ in
|
||||
let req = List.add(list, accounts: [accountID])
|
||||
mastodonController.run(req) { response in
|
||||
if case .failure(let error) = response {
|
||||
self.handleError(error, title: "Error Adding to List")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
elementHandler([UIMenu(title: "Add to List", image: UIImage(systemName: "list.bullet"), children: listActions)])
|
||||
}))
|
||||
suppressSection.append(relationshipAction(accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.blockAction(for: $0, mastodonController: $1) }))
|
||||
suppressSection.append(relationshipAction(accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.muteAction(for: $0, mastodonController: $1) }))
|
||||
}
|
||||
|
@ -124,7 +138,7 @@ extension MenuActionProvider {
|
|||
]
|
||||
}
|
||||
|
||||
func actionsForStatus(_ status: StatusMO, sourceView: UIView?, includeReply: Bool = true) -> [UIMenuElement] {
|
||||
func actionsForStatus(_ status: StatusMO, sourceView: UIView?, includeStatusButtonActions: Bool = true) -> [UIMenuElement] {
|
||||
guard let mastodonController = mastodonController else { return [] }
|
||||
|
||||
guard let accountID = mastodonController.accountInfo?.id else {
|
||||
|
@ -155,7 +169,8 @@ extension MenuActionProvider {
|
|||
}),
|
||||
]
|
||||
|
||||
if #available(iOS 16.0, *) {
|
||||
if #available(iOS 16.0, *),
|
||||
includeStatusButtonActions {
|
||||
let favorited = status.favourited
|
||||
// TODO: move this color into an asset catalog or something
|
||||
var favImage = UIImage(systemName: favorited ? "star.fill" : "star")!
|
||||
|
@ -186,7 +201,7 @@ extension MenuActionProvider {
|
|||
|
||||
var actionsSection: [UIAction] = []
|
||||
|
||||
if includeReply {
|
||||
if includeStatusButtonActions {
|
||||
actionsSection.insert(createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { [weak self] (_) in
|
||||
guard let self = self else { return }
|
||||
self.navigationDelegate?.compose(inReplyToID: status.id)
|
||||
|
@ -264,8 +279,9 @@ extension MenuActionProvider {
|
|||
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID))
|
||||
|
||||
if #available(iOS 16.0, *) {
|
||||
let toggleableAndActions = toggleableSection + actionsSection
|
||||
return [
|
||||
UIMenu(options: .displayInline, preferredElementSize: .medium, children: toggleableSection + actionsSection),
|
||||
UIMenu(options: .displayInline, preferredElementSize: toggleableAndActions.count == 1 ? .large : .medium, children: toggleableAndActions),
|
||||
UIMenu(options: .displayInline, children: shareSection),
|
||||
]
|
||||
} else {
|
||||
|
|
|
@ -19,7 +19,7 @@ protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeCon
|
|||
var controller: TimelineLikeController<TimelineItem>! { get }
|
||||
var confirmLoadMore: PassthroughSubject<Void, Never> { get }
|
||||
|
||||
var collectionView: UICollectionView { get }
|
||||
var collectionView: UICollectionView! { get }
|
||||
var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get }
|
||||
}
|
||||
|
||||
|
@ -64,7 +64,7 @@ extension TimelineLikeCollectionViewController {
|
|||
}
|
||||
|
||||
func handleAddLoadingIndicator() async {
|
||||
if case .loadingInitial(_, _) = await controller.state,
|
||||
if case .loadingInitial(_, _) = controller.state,
|
||||
let refreshControl = collectionView.refreshControl,
|
||||
refreshControl.isRefreshing {
|
||||
refreshControl.beginRefreshing()
|
||||
|
@ -85,7 +85,7 @@ extension TimelineLikeCollectionViewController {
|
|||
}
|
||||
|
||||
func handleRemoveLoadingIndicator() async {
|
||||
if case .loadingInitial(_, _) = await controller.state,
|
||||
if case .loadingInitial(_, _) = controller.state,
|
||||
let refreshControl = collectionView.refreshControl,
|
||||
refreshControl.isRefreshing {
|
||||
refreshControl.endRefreshing()
|
||||
|
@ -179,6 +179,17 @@ extension TimelineLikeCollectionViewController {
|
|||
snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries)
|
||||
await apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
|
||||
func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] {
|
||||
fatalError("not supported by \(String(describing: type(of: self)))")
|
||||
}
|
||||
|
||||
func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async {
|
||||
}
|
||||
|
||||
func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineGapDirection) async {
|
||||
fatalError("not supported by \(String(describing: type(of: self)))")
|
||||
}
|
||||
}
|
||||
|
||||
extension TimelineLikeCollectionViewController {
|
||||
|
@ -206,7 +217,7 @@ extension TimelineLikeCollectionViewController {
|
|||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell
|
||||
cell.confirmLoadMore = self.confirmLoadMore
|
||||
Task {
|
||||
if case .loadingOlder(_, _) = await controller.state {
|
||||
if case .loadingOlder(_, _) = controller.state {
|
||||
cell.isLoading = true
|
||||
} else {
|
||||
cell.isLoading = false
|
||||
|
|
|
@ -22,6 +22,18 @@ extension NSUserActivity {
|
|||
}
|
||||
}
|
||||
|
||||
var isStateRestorationActivity: Bool {
|
||||
get {
|
||||
(userInfo?["isStateRestorationActivity"] as? Bool) ?? false
|
||||
}
|
||||
set {
|
||||
if userInfo == nil {
|
||||
userInfo = [:]
|
||||
}
|
||||
userInfo!["isStateRestorationActivity"] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
convenience init(type: UserActivityType) {
|
||||
self.init(activityType: type.rawValue)
|
||||
}
|
||||
|
|
|
@ -16,9 +16,11 @@ protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
|
|||
|
||||
func loadNewer() async throws -> [TimelineItem]
|
||||
|
||||
func canLoadOlder() async -> Bool
|
||||
|
||||
func loadOlder() async throws -> [TimelineItem]
|
||||
|
||||
func canLoadOlder() async -> Bool
|
||||
func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem]
|
||||
|
||||
func handleAddLoadingIndicator() async
|
||||
func handleRemoveLoadingIndicator() async
|
||||
|
@ -28,13 +30,16 @@ protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
|
|||
func handlePrependItems(_ timelineItems: [TimelineItem]) async
|
||||
func handleLoadOlderError(_ error: Swift.Error) async
|
||||
func handleAppendItems(_ timelineItems: [TimelineItem]) async
|
||||
func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async
|
||||
func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineGapDirection) async
|
||||
}
|
||||
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController")
|
||||
|
||||
actor TimelineLikeController<Item> {
|
||||
@MainActor
|
||||
class TimelineLikeController<Item> {
|
||||
|
||||
unowned var delegate: any TimelineLikeControllerDelegate<Item>
|
||||
private unowned var delegate: any TimelineLikeControllerDelegate<Item>
|
||||
|
||||
private(set) var state = State.notLoadedInitial {
|
||||
willSet {
|
||||
|
@ -74,6 +79,16 @@ actor TimelineLikeController<Item> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Used to indicate to the controller that the initial set of posts have been restored externally.
|
||||
func restoreInitial(doRestore: () -> Void) {
|
||||
guard state == .notLoadedInitial else {
|
||||
return
|
||||
}
|
||||
state = .restoringInitial
|
||||
doRestore()
|
||||
state = .idle
|
||||
}
|
||||
|
||||
func loadNewer() async {
|
||||
guard state == .idle else {
|
||||
return
|
||||
|
@ -126,6 +141,27 @@ actor TimelineLikeController<Item> {
|
|||
}
|
||||
}
|
||||
|
||||
func fillGap(in direction: TimelineGapDirection) async {
|
||||
guard state == .idle else {
|
||||
return
|
||||
}
|
||||
let token = LoadAttemptToken()
|
||||
state = .loadingGap(token, direction)
|
||||
do {
|
||||
let items = try await delegate.loadGap(in: direction)
|
||||
guard case .loadingGap(token, direction) = state else {
|
||||
return
|
||||
}
|
||||
await emit(event: .fillGap(items, direction, token))
|
||||
state = .idle
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
await emit(event: .loadGapError(error, direction, token))
|
||||
state = .idle
|
||||
}
|
||||
}
|
||||
|
||||
private func transition(to newState: State) {
|
||||
self.state = newState
|
||||
}
|
||||
|
@ -152,15 +188,21 @@ actor TimelineLikeController<Item> {
|
|||
await delegate.handleLoadOlderError(error)
|
||||
case .appendItems(let items, _):
|
||||
await delegate.handleAppendItems(items)
|
||||
case .loadGapError(let error, let direction, _):
|
||||
await delegate.handleLoadGapError(error, direction: direction)
|
||||
case .fillGap(let items, let direction, _):
|
||||
await delegate.handleFillGap(items, direction: direction)
|
||||
}
|
||||
}
|
||||
|
||||
enum State: Equatable, CustomDebugStringConvertible {
|
||||
case notLoadedInitial
|
||||
case idle
|
||||
case restoringInitial
|
||||
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
||||
case loadingNewer(LoadAttemptToken)
|
||||
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
||||
case loadingGap(LoadAttemptToken, TimelineGapDirection)
|
||||
|
||||
var debugDescription: String {
|
||||
switch self {
|
||||
|
@ -168,12 +210,16 @@ actor TimelineLikeController<Item> {
|
|||
return "notLoadedInitial"
|
||||
case .idle:
|
||||
return "idle"
|
||||
case .restoringInitial:
|
||||
return "restoringInitial"
|
||||
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
||||
return "loadingInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
||||
case .loadingNewer(let token):
|
||||
return "loadingNewer(\(ObjectIdentifier(token)))"
|
||||
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
||||
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
||||
case .loadingGap(let token, let direction):
|
||||
return "loadingGap(\(ObjectIdentifier(token)), \(direction))"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -181,24 +227,28 @@ actor TimelineLikeController<Item> {
|
|||
switch self {
|
||||
case .notLoadedInitial:
|
||||
switch to {
|
||||
case .loadingInitial(_, _):
|
||||
case .restoringInitial, .loadingInitial(_, _):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
case .idle:
|
||||
switch to {
|
||||
case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _):
|
||||
case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
case .restoringInitial:
|
||||
return to == .idle
|
||||
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
||||
return to == .notLoadedInitial || to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true))
|
||||
case .loadingNewer(_):
|
||||
return to == .idle
|
||||
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
||||
return to == .idle || (!hasAddedLoadingIndicator && to == .loadingOlder(token, hasAddedLoadingIndicator: true))
|
||||
case .loadingGap(_, _):
|
||||
return to == .idle
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -239,6 +289,13 @@ actor TimelineLikeController<Item> {
|
|||
default:
|
||||
return false
|
||||
}
|
||||
case .loadGapError(_, let direction, let token), .fillGap(_, let direction, let token):
|
||||
switch self {
|
||||
case .loadingGap(token, direction):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -252,6 +309,8 @@ actor TimelineLikeController<Item> {
|
|||
case prependItems([Item], LoadAttemptToken)
|
||||
case loadOlderError(Error, LoadAttemptToken)
|
||||
case appendItems([Item], LoadAttemptToken)
|
||||
case loadGapError(Error, TimelineGapDirection, LoadAttemptToken)
|
||||
case fillGap([Item], TimelineGapDirection, LoadAttemptToken)
|
||||
|
||||
var debugDescription: String {
|
||||
switch self {
|
||||
|
@ -271,6 +330,10 @@ actor TimelineLikeController<Item> {
|
|||
return "loadOlderError(\(error), \(token))"
|
||||
case .appendItems(_, let token):
|
||||
return "appendItems(<omitted>, \(token))"
|
||||
case .loadGapError(let error, let direction, let token):
|
||||
return "loadGapError(\(error), \(direction), \(token))"
|
||||
case .fillGap(_, let direction, let token):
|
||||
return "loadGapError(<omitted>, \(direction), \(token))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -310,3 +373,10 @@ actor TimelineLikeController<Item> {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
enum TimelineGapDirection {
|
||||
/// Fill in below the gap. I.e., statuses that are immediately newer than the status below the gap.
|
||||
case below
|
||||
/// Fill in above the gap. I.e., statuses that are immediately older than the status above the gap.
|
||||
case above
|
||||
}
|
||||
|
|
|
@ -178,13 +178,13 @@ extension TuskerNavigationDelegate {
|
|||
}
|
||||
|
||||
func showFollowedByList(accountIDs: [String]) {
|
||||
let vc = AccountListTableViewController(accountIDs: accountIDs, mastodonController: apiController)
|
||||
let vc = AccountListViewController(accountIDs: accountIDs, mastodonController: apiController)
|
||||
vc.title = NSLocalizedString("Followed By", comment: "followed by accounts list title")
|
||||
show(vc, sender: self)
|
||||
}
|
||||
|
||||
func statusActionAccountList(action: StatusActionAccountListTableViewController.ActionType, statusID: String, statusState state: StatusState, accountIDs: [String]?) -> StatusActionAccountListTableViewController {
|
||||
return StatusActionAccountListTableViewController(actionType: action, statusID: statusID, statusState: state, accountIDs: accountIDs, mastodonController: apiController)
|
||||
func statusActionAccountList(action: StatusActionAccountListViewController.ActionType, statusID: String, statusState state: StatusState, accountIDs: [String]?) -> StatusActionAccountListViewController {
|
||||
return StatusActionAccountListViewController(actionType: action, statusID: statusID, statusState: state, accountIDs: accountIDs, mastodonController: apiController)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,148 @@
|
|||
//
|
||||
// AccountCollectionViewCell.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 11/22/22.
|
||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftSoup
|
||||
|
||||
class AccountCollectionViewCell: UICollectionViewListCell {
|
||||
|
||||
private lazy var hStack = UIStackView(arrangedSubviews: [
|
||||
avatarImageView,
|
||||
vStack,
|
||||
]).configure {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 8
|
||||
$0.alignment = .leading
|
||||
}
|
||||
|
||||
private let avatarImageView = CachedImageView(cache: .avatars).configure {
|
||||
$0.layer.masksToBounds = true
|
||||
NSLayoutConstraint.activate([
|
||||
$0.widthAnchor.constraint(equalToConstant: 50),
|
||||
$0.heightAnchor.constraint(equalToConstant: 50),
|
||||
])
|
||||
}
|
||||
|
||||
private lazy var vStack = UIStackView(arrangedSubviews: [
|
||||
displayNameLabel,
|
||||
usernameLabel,
|
||||
noteLabel,
|
||||
]).configure {
|
||||
$0.axis = .vertical
|
||||
$0.spacing = 4
|
||||
$0.alignment = .leading
|
||||
}
|
||||
|
||||
private let displayNameLabel = EmojiLabel().configure {
|
||||
$0.font = .preferredFont(forTextStyle: .body)
|
||||
$0.adjustsFontSizeToFitWidth = true
|
||||
$0.adjustsFontForContentSizeCategory = true
|
||||
}
|
||||
|
||||
private let usernameLabel = UILabel().configure {
|
||||
$0.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .light))
|
||||
$0.textColor = .secondaryLabel
|
||||
$0.adjustsFontSizeToFitWidth = true
|
||||
$0.adjustsFontForContentSizeCategory = true
|
||||
}
|
||||
|
||||
private let noteLabel = EmojiLabel().configure {
|
||||
$0.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15))
|
||||
$0.numberOfLines = 2
|
||||
}
|
||||
|
||||
weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
|
||||
var mastodonController: MastodonController! { delegate?.apiController }
|
||||
|
||||
private var accountID: String?
|
||||
private var isGrayscale = false
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: .zero)
|
||||
|
||||
hStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(hStack)
|
||||
NSLayoutConstraint.activate([
|
||||
hStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8),
|
||||
hStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
|
||||
hStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
|
||||
hStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
|
||||
])
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: Accessibility
|
||||
|
||||
override var isAccessibilityElement: Bool {
|
||||
get { true }
|
||||
set {}
|
||||
}
|
||||
|
||||
override var accessibilityAttributedLabel: NSAttributedString? {
|
||||
get {
|
||||
guard let accountID,
|
||||
let account = mastodonController.persistentContainer.account(for: accountID) else {
|
||||
return nil
|
||||
}
|
||||
var str = AttributedString(account.displayOrUserName)
|
||||
str += ", @"
|
||||
str += AttributedString(account.acct)
|
||||
return NSAttributedString(str)
|
||||
}
|
||||
set {}
|
||||
}
|
||||
|
||||
override func accessibilityActivate() -> Bool {
|
||||
guard let accountID else {
|
||||
return false
|
||||
}
|
||||
delegate?.selected(account: accountID)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: Configure UI
|
||||
|
||||
func updateUI(accountID: String) {
|
||||
guard let account = mastodonController.persistentContainer.account(for: accountID) else {
|
||||
fatalError()
|
||||
}
|
||||
self.accountID = accountID
|
||||
|
||||
avatarImageView.update(for: account.avatar)
|
||||
usernameLabel.text = "@\(account.acct)"
|
||||
updateUIForPreferences(account: account)
|
||||
}
|
||||
|
||||
private func updateUIForPreferences(account: AccountMO) {
|
||||
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 50
|
||||
isGrayscale = Preferences.shared.grayscaleImages
|
||||
if Preferences.shared.hideCustomEmojiInUsernames {
|
||||
displayNameLabel.text = account.displayNameWithoutCustomEmoji
|
||||
} else {
|
||||
displayNameLabel.text = account.displayOrUserName
|
||||
displayNameLabel.setEmojis(account.emojis, identifier: account.id)
|
||||
}
|
||||
|
||||
let doc = try! SwiftSoup.parseBodyFragment(account.note)
|
||||
noteLabel.text = try! doc.text()
|
||||
noteLabel.setEmojis(account.emojis, identifier: account.id)
|
||||
}
|
||||
|
||||
@objc private func preferencesChanged() {
|
||||
if let accountID,
|
||||
let account = mastodonController?.persistentContainer.account(for: accountID) {
|
||||
updateUIForPreferences(account: account)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -56,6 +56,7 @@ class CachedImageView: UIImageView {
|
|||
guard let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) else {
|
||||
return
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
self.image = transformedImage
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ class ConfirmLoadMoreCollectionViewCell: UICollectionViewCell {
|
|||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
backgroundColor = .secondarySystemBackground
|
||||
backgroundColor = .systemGroupedBackground
|
||||
|
||||
let label = UILabel()
|
||||
label.text = "Infinite scrolling is off. Do you want to keep going?"
|
||||
|
|
|
@ -218,7 +218,7 @@ extension ActionNotificationGroupTableViewCell: SelectableTableViewCell {
|
|||
guard let delegate = delegate else { return }
|
||||
let notifications = group.notifications
|
||||
let accountIDs = notifications.map { $0.account.id }
|
||||
let action: StatusActionAccountListTableViewController.ActionType
|
||||
let action: StatusActionAccountListViewController.ActionType
|
||||
switch notifications.first!.kind {
|
||||
case .favourite:
|
||||
action = .favorite
|
||||
|
@ -228,6 +228,7 @@ extension ActionNotificationGroupTableViewCell: SelectableTableViewCell {
|
|||
fatalError()
|
||||
}
|
||||
let vc = delegate.statusActionAccountList(action: action, statusID: statusID, statusState: .unknown, accountIDs: accountIDs)
|
||||
vc.showInacurateCountWarning = false
|
||||
delegate.show(vc)
|
||||
}
|
||||
}
|
||||
|
@ -235,9 +236,12 @@ extension ActionNotificationGroupTableViewCell: SelectableTableViewCell {
|
|||
extension ActionNotificationGroupTableViewCell: MenuPreviewProvider {
|
||||
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
|
||||
return (content: {
|
||||
guard let delegate = self.delegate else {
|
||||
return nil
|
||||
}
|
||||
let notifications = self.group.notifications
|
||||
let accountIDs = notifications.map { $0.account.id }
|
||||
let action: StatusActionAccountListTableViewController.ActionType
|
||||
let action: StatusActionAccountListViewController.ActionType
|
||||
switch notifications.first!.kind {
|
||||
case .favourite:
|
||||
action = .favorite
|
||||
|
@ -246,7 +250,9 @@ extension ActionNotificationGroupTableViewCell: MenuPreviewProvider {
|
|||
default:
|
||||
fatalError()
|
||||
}
|
||||
return self.delegate?.statusActionAccountList(action: action, statusID: self.statusID, statusState: .unknown, accountIDs: accountIDs)
|
||||
let vc = delegate.statusActionAccountList(action: action, statusID: self.statusID, statusState: .unknown, accountIDs: accountIDs)
|
||||
vc.showInacurateCountWarning = false
|
||||
return vc
|
||||
}, actions: {
|
||||
return []
|
||||
})
|
||||
|
|
|
@ -210,7 +210,7 @@ extension FollowNotificationGroupTableViewCell: MenuPreviewProvider {
|
|||
if accountIDs.count == 1 {
|
||||
return ProfileViewController(accountID: accountIDs.first!, mastodonController: mastodonController)
|
||||
} else {
|
||||
return AccountListTableViewController(accountIDs: accountIDs, mastodonController: mastodonController)
|
||||
return AccountListViewController(accountIDs: accountIDs, mastodonController: mastodonController)
|
||||
}
|
||||
}, actions: {
|
||||
if accountIDs.count == 1 {
|
||||
|
|
|
@ -95,7 +95,7 @@ class StatusPollView: UIView {
|
|||
guard let poll = poll else { return }
|
||||
|
||||
// poll.voted is nil if there is no user (e.g., public timeline), in which case the poll also cannot be voted upon
|
||||
if (poll.voted ?? true) || poll.expired || status.account.id == mastodonController.account.id {
|
||||
if (poll.voted ?? true) || poll.expired || status.account.id == mastodonController.account?.id {
|
||||
canVote = false
|
||||
} else {
|
||||
canVote = true
|
||||
|
@ -126,7 +126,7 @@ class StatusPollView: UIView {
|
|||
if expired {
|
||||
voteButton.disabledTitle = "Expired"
|
||||
} else if poll.voted ?? false {
|
||||
if status.account.id == mastodonController.account.id {
|
||||
if status.account.id == mastodonController.account?.id {
|
||||
voteButton.isHidden = true
|
||||
} else {
|
||||
voteButton.disabledTitle = "Voted"
|
||||
|
|
|
@ -7,13 +7,15 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import SwiftUI
|
||||
|
||||
class ProfileFieldsView: UIView {
|
||||
|
||||
weak var delegate: ProfileHeaderViewDelegate?
|
||||
|
||||
private let stack = UIStackView()
|
||||
private var fieldViews: [(EmojiLabel, ContentTextView)] = []
|
||||
private var fieldViews: [(EmojiLabel, ProfileFieldValueView)] = []
|
||||
private var fieldConstraints: [NSLayoutConstraint] = []
|
||||
|
||||
private var isUsingSingleColumn: Bool = false
|
||||
|
@ -80,16 +82,11 @@ class ProfileFieldsView: UIView {
|
|||
nameLabel.setEmojis(account.emojis, identifier: account.id)
|
||||
nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
let valueTextView = ContentTextView()
|
||||
valueTextView.isSelectable = false
|
||||
valueTextView.defaultFont = .preferredFont(forTextStyle: .body)
|
||||
valueTextView.adjustsFontForContentSizeCategory = true
|
||||
valueTextView.setTextFromHtml(field.value)
|
||||
valueTextView.setEmojis(account.emojis, identifier: account.id)
|
||||
valueTextView.navigationDelegate = delegate
|
||||
valueTextView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
let valueView = ProfileFieldValueView(field: field, account: account)
|
||||
valueView.navigationDelegate = delegate
|
||||
valueView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
fieldViews.append((nameLabel, valueTextView))
|
||||
fieldViews.append((nameLabel, valueView))
|
||||
}
|
||||
|
||||
configureFields()
|
||||
|
@ -121,7 +118,7 @@ class ProfileFieldsView: UIView {
|
|||
}
|
||||
name.textAlignment = .natural
|
||||
stack.addArrangedSubview(name)
|
||||
value.textAlignment = .natural
|
||||
value.setTextAlignment(.natural)
|
||||
stack.addArrangedSubview(value)
|
||||
}
|
||||
} else {
|
||||
|
@ -137,7 +134,7 @@ class ProfileFieldsView: UIView {
|
|||
name.textAlignment = .right
|
||||
name.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
value.textAlignment = .left
|
||||
value.setTextAlignment(.left)
|
||||
value.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let fieldContainer = UIView()
|
||||
|
@ -165,3 +162,159 @@ class ProfileFieldsView: UIView {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
private class ProfileFieldValueView: UIView {
|
||||
weak var navigationDelegate: TuskerNavigationDelegate? {
|
||||
didSet {
|
||||
textView.navigationDelegate = navigationDelegate
|
||||
}
|
||||
}
|
||||
|
||||
private let account: AccountMO
|
||||
private let field: Account.Field
|
||||
|
||||
private let textView = ContentTextView()
|
||||
private var iconView: UIView?
|
||||
|
||||
init(field: Account.Field, account: AccountMO) {
|
||||
self.account = account
|
||||
self.field = field
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
textView.isSelectable = false
|
||||
textView.defaultFont = .preferredFont(forTextStyle: .body)
|
||||
textView.adjustsFontForContentSizeCategory = true
|
||||
textView.setTextFromHtml(field.value)
|
||||
textView.setEmojis(account.emojis, identifier: account.id)
|
||||
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
textView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(textView)
|
||||
|
||||
let textViewTrailingConstraint: NSLayoutConstraint
|
||||
|
||||
if field.verifiedAt != nil {
|
||||
var config = UIButton.Configuration.plain()
|
||||
config.image = UIImage(systemName: "checkmark")
|
||||
config.baseForegroundColor = .systemGreen
|
||||
let icon = UIButton(configuration: config)
|
||||
self.iconView = icon
|
||||
icon.translatesAutoresizingMaskIntoConstraints = false
|
||||
icon.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
icon.addTarget(self, action: #selector(verifiedIconTapped), for: .touchUpInside)
|
||||
icon.isPointerInteractionEnabled = true
|
||||
icon.accessibilityLabel = "Verified link"
|
||||
addSubview(icon)
|
||||
textViewTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor, constant: -4)
|
||||
NSLayoutConstraint.activate([
|
||||
icon.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
icon.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
} else {
|
||||
textViewTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
textView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
textViewTrailingConstraint,
|
||||
textView.topAnchor.constraint(equalTo: topAnchor),
|
||||
textView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func setTextAlignment(_ alignment: NSTextAlignment) {
|
||||
textView.textAlignment = alignment
|
||||
}
|
||||
|
||||
@objc private func verifiedIconTapped() {
|
||||
guard let navigationDelegate else {
|
||||
return
|
||||
}
|
||||
let view = ProfileFieldVerificationView(
|
||||
acct: account.acct,
|
||||
verifiedAt: field.verifiedAt!,
|
||||
linkText: textView.text,
|
||||
navigationDelegate: navigationDelegate
|
||||
)
|
||||
let host = UIHostingController(rootView: view)
|
||||
let toPresent: UIViewController
|
||||
if traitCollection.horizontalSizeClass == .compact || traitCollection.verticalSizeClass == .compact {
|
||||
toPresent = UINavigationController(rootViewController: host)
|
||||
toPresent.modalPresentationStyle = .pageSheet
|
||||
let sheetPresentationController = toPresent.sheetPresentationController!
|
||||
sheetPresentationController.detents = [
|
||||
.medium()
|
||||
]
|
||||
} else {
|
||||
host.modalPresentationStyle = .popover
|
||||
let popoverPresentationController = host.popoverPresentationController!
|
||||
popoverPresentationController.sourceView = iconView
|
||||
host.preferredContentSize = host.sizeThatFits(in: CGSize(width: 400, height: CGFloat.infinity))
|
||||
toPresent = host
|
||||
}
|
||||
navigationDelegate.present(toPresent, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProfileFieldVerificationView: View {
|
||||
let acct: String
|
||||
let verifiedAt: Date
|
||||
let linkText: String
|
||||
let navigationDelegate: TuskerNavigationDelegate
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
firstLine
|
||||
secondLine
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle(Text("Verified Link"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") {
|
||||
navigationDelegate.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.environment(\.openURL, OpenURLAction(handler: { url in
|
||||
// dismiss the sheet/popover first
|
||||
navigationDelegate.dismiss(animated: true) {
|
||||
navigationDelegate.selected(url: url)
|
||||
}
|
||||
return .handled
|
||||
}))
|
||||
}
|
||||
|
||||
private var firstLine: Text {
|
||||
var attrStr: AttributedString = "This link has been verified by your instance, "
|
||||
var instance = AttributedString(navigationDelegate.apiController!.instanceURL.host!)
|
||||
instance.font = .body.bold()
|
||||
attrStr += instance
|
||||
attrStr += "."
|
||||
return Text(attrStr)
|
||||
}
|
||||
|
||||
private var secondLine: Text {
|
||||
var attrStr: AttributedString = "The page at "
|
||||
var linkStr: AttributedString
|
||||
if linkText.count > 43 {
|
||||
linkStr = AttributedString(linkText.prefix(40) + "…")
|
||||
} else {
|
||||
linkStr = AttributedString(linkText)
|
||||
}
|
||||
linkStr.link = URL(string: linkText)
|
||||
attrStr += linkStr
|
||||
attrStr += " was confirmed to link back to "
|
||||
var acctStr = AttributedString("@\(acct)")
|
||||
acctStr.font = .body.bold()
|
||||
attrStr += acctStr
|
||||
attrStr += AttributedString(" as of \(verifiedAt.formatted(date: .abbreviated, time: .shortened)).")
|
||||
return Text(attrStr)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -170,7 +170,7 @@ class BaseStatusTableViewCell: UITableViewCell {
|
|||
let reblogDisabled: Bool
|
||||
if mastodonController.instanceFeatures.boostToOriginalAudience {
|
||||
// Pleroma allows 'Boost to original audience' for your own private posts
|
||||
reblogDisabled = status.visibility == .direct || (status.visibility == .private && status.account.id != mastodonController.account.id)
|
||||
reblogDisabled = status.visibility == .direct || (status.visibility == .private && status.account.id != mastodonController.account?.id)
|
||||
} else {
|
||||
reblogDisabled = status.visibility == .private || status.visibility == .direct
|
||||
}
|
||||
|
@ -209,7 +209,7 @@ class BaseStatusTableViewCell: UITableViewCell {
|
|||
|
||||
// keep menu in sync with changed states e.g. bookmarked, muted
|
||||
// do not include reply action here, because the cell already contains a button for it
|
||||
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, sourceView: moreButton, includeReply: false) ?? [])
|
||||
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, sourceView: moreButton, includeStatusButtonActions: false) ?? [])
|
||||
|
||||
pollView.isHidden = status.poll == nil
|
||||
pollView.mastodonController = mastodonController
|
||||
|
|
|
@ -131,7 +131,7 @@ extension StatusCollectionViewCell {
|
|||
}
|
||||
if status.visibility == .direct || status.visibility == .private {
|
||||
if mastodonController.instanceFeatures.boostToOriginalAudience,
|
||||
status.account.id == mastodonController.account.id {
|
||||
status.account.id == mastodonController.account?.id {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
@ -195,7 +195,7 @@ extension StatusCollectionViewCell {
|
|||
|
||||
// keep menu in sync with changed states e.g. bookmarked, muted
|
||||
// do not include reply action here, because the cell already contains a button for it
|
||||
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, sourceView: moreButton, includeReply: false) ?? [])
|
||||
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, sourceView: moreButton, includeStatusButtonActions: false) ?? [])
|
||||
|
||||
contentContainer.pollView.isHidden = status.poll == nil
|
||||
contentContainer.pollView.mastodonController = mastodonController
|
||||
|
|
|
@ -16,10 +16,20 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
|
||||
// MARK: Subviews
|
||||
|
||||
private lazy var reblogLabel = EmojiLabel().configure {
|
||||
private lazy var rebloggerLabel = EmojiLabel().configure {
|
||||
$0.textColor = .secondaryLabel
|
||||
$0.font = .preferredFont(forTextStyle: .body)
|
||||
$0.adjustsFontForContentSizeCategory = true
|
||||
}
|
||||
private let reblogIcon = UIImageView(image: UIImage(systemName: "repeat")).configure {
|
||||
$0.tintColor = .secondaryLabel
|
||||
}
|
||||
private lazy var reblogHStack = UIStackView(arrangedSubviews: [
|
||||
reblogIcon,
|
||||
rebloggerLabel,
|
||||
]).configure {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 8
|
||||
// this needs to have a higher priorty than the content container's zero height constraint
|
||||
$0.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||
$0.isUserInteractionEnabled = true
|
||||
|
@ -265,12 +275,12 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
for subview in [reblogLabel, mainContainer, actionsContainer] {
|
||||
for subview in [reblogHStack, mainContainer, actionsContainer] {
|
||||
subview.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(subview)
|
||||
}
|
||||
|
||||
mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: reblogLabel.bottomAnchor, constant: 4)
|
||||
mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: reblogHStack.bottomAnchor, constant: 4)
|
||||
mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8)
|
||||
mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor, constant: -4)
|
||||
mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6)
|
||||
|
@ -281,9 +291,9 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
|
||||
NSLayoutConstraint.activate([
|
||||
// why is this 4 but the mainContainerTopSelfConstraint constant 8? because this looks more balanced
|
||||
reblogLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4),
|
||||
reblogLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||
reblogLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
||||
reblogHStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4),
|
||||
rebloggerLabel.leadingAnchor.constraint(equalTo: contentVStack.leadingAnchor),
|
||||
reblogHStack.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -16),
|
||||
|
||||
mainContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||
mainContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
||||
|
@ -399,7 +409,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
if let rebloggedStatus = status.reblog {
|
||||
reblogStatusID = statusID
|
||||
rebloggerID = status.account.id
|
||||
reblogLabel.isHidden = false
|
||||
reblogHStack.isHidden = false
|
||||
mainContainerTopToReblogLabelConstraint.isActive = true
|
||||
mainContainerTopToSelfConstraint.isActive = false
|
||||
updateRebloggerLabel(reblogger: status.account)
|
||||
|
@ -408,7 +418,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
} else {
|
||||
reblogStatusID = nil
|
||||
rebloggerID = nil
|
||||
reblogLabel.isHidden = true
|
||||
reblogHStack.isHidden = true
|
||||
mainContainerTopToReblogLabelConstraint.isActive = false
|
||||
mainContainerTopToSelfConstraint.isActive = true
|
||||
}
|
||||
|
@ -492,11 +502,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
|
||||
private func updateRebloggerLabel(reblogger: AccountMO) {
|
||||
if Preferences.shared.hideCustomEmojiInUsernames {
|
||||
reblogLabel.text = "Reblogged by \(reblogger.displayNameWithoutCustomEmoji)"
|
||||
reblogLabel.removeEmojis()
|
||||
rebloggerLabel.text = "\(reblogger.displayNameWithoutCustomEmoji) reblogged"
|
||||
rebloggerLabel.removeEmojis()
|
||||
} else {
|
||||
reblogLabel.text = "Reblogged by \(reblogger.displayOrUserName)"
|
||||
reblogLabel.setEmojis(reblogger.emojis, identifier: reblogger.id)
|
||||
rebloggerLabel.text = "\(reblogger.displayOrUserName) reblogged"
|
||||
rebloggerLabel.setEmojis(reblogger.emojis, identifier: reblogger.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import Pachyderm
|
|||
|
||||
struct ToastConfiguration {
|
||||
var systemImageName: String?
|
||||
var titleFont: UIFont = .boldSystemFont(ofSize: 14)
|
||||
var titleFont: UIFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .boldSystemFont(ofSize: 14))
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var actionTitle: String?
|
||||
|
|
|
@ -68,13 +68,15 @@ class ToastView: UIView {
|
|||
titleLabel.textColor = .white
|
||||
titleLabel.font = configuration.titleFont
|
||||
titleLabel.adjustsFontSizeToFitWidth = true
|
||||
titleLabel.adjustsFontForContentSizeCategory = true
|
||||
|
||||
if let subtitle = configuration.subtitle {
|
||||
let subtitleLabel = UILabel()
|
||||
subtitleLabel.text = subtitle
|
||||
subtitleLabel.textColor = .white
|
||||
subtitleLabel.numberOfLines = 0
|
||||
subtitleLabel.font = .systemFont(ofSize: 14)
|
||||
subtitleLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14))
|
||||
subtitleLabel.adjustsFontForContentSizeCategory = true
|
||||
let vStack = UIStackView(arrangedSubviews: [
|
||||
titleLabel,
|
||||
subtitleLabel
|
||||
|
@ -89,7 +91,8 @@ class ToastView: UIView {
|
|||
if let actionTitle = configuration.actionTitle {
|
||||
let actionLabel = UILabel()
|
||||
actionLabel.text = actionTitle
|
||||
actionLabel.font = .boldSystemFont(ofSize: 16)
|
||||
actionLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .boldSystemFont(ofSize: 16))
|
||||
actionLabel.adjustsFontForContentSizeCategory = true
|
||||
actionLabel.textColor = .white
|
||||
stack.addArrangedSubview(actionLabel)
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ class VisualEffectImageButton: UIControl {
|
|||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
let blur = UIBlurEffect(style: .prominent)
|
||||
let blur = UIBlurEffect(style: .systemThickMaterial)
|
||||
let blurView = UIVisualEffectView(effect: blur)
|
||||
blurView.translatesAutoresizingMaskIntoConstraints = false
|
||||
let vibrancy = UIVibrancyEffect(blurEffect: blur, style: .label)
|
||||
|
|
Loading…
Reference in New Issue