Compare commits
No commits in common. "76fc73de957b9199179656bb88e84b1568090de1" and "dd82283341347db88ed49bb7a1545c8193af0722" have entirely different histories.
76fc73de95
...
dd82283341
32
CHANGELOG.md
32
CHANGELOG.md
|
@ -1,37 +1,5 @@
|
||||||
# Changelog
|
# 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)
|
## 2022.1 (45)
|
||||||
Features/Improvements:
|
Features/Improvements:
|
||||||
- iPhone: Temporarily hide the Compose screen by swiping down to access the rest of the applies
|
- iPhone: Temporarily hide the Compose screen by swiping down to access the rest of the applies
|
||||||
|
|
|
@ -167,12 +167,5 @@ extension Account {
|
||||||
public struct Field: Codable {
|
public struct Field: Codable {
|
||||||
public let name: String
|
public let name: String
|
||||||
public let value: String
|
public let value: String
|
||||||
public let verifiedAt: Date?
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case name
|
|
||||||
case value
|
|
||||||
case verifiedAt = "verified_at"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -79,6 +79,5 @@ extension Card {
|
||||||
case link
|
case link
|
||||||
case photo
|
case photo
|
||||||
case video
|
case video
|
||||||
case rich
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,12 +17,11 @@ public class List: Decodable, Equatable, Hashable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func ==(lhs: List, rhs: List) -> Bool {
|
public static func ==(lhs: List, rhs: List) -> Bool {
|
||||||
return lhs.id == rhs.id && lhs.title == rhs.title
|
return lhs.id == rhs.id
|
||||||
}
|
}
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
public func hash(into hasher: inout Hasher) {
|
||||||
hasher.combine(id)
|
hasher.combine(id)
|
||||||
hasher.combine(title)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
|
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
|
||||||
|
|
|
@ -11,9 +11,7 @@ import Foundation
|
||||||
public enum RequestRange {
|
public enum RequestRange {
|
||||||
case `default`
|
case `default`
|
||||||
case count(Int)
|
case count(Int)
|
||||||
/// Chronologically immediately before the given ID
|
|
||||||
case before(id: String, count: Int?)
|
case before(id: String, count: Int?)
|
||||||
/// Chronologically immediately after the given ID
|
|
||||||
case after(id: String, count: Int?)
|
case after(id: String, count: Int?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class StatusState: Equatable {
|
public class StatusState: Equatable, Hashable {
|
||||||
public var collapsible: Bool?
|
public var collapsible: Bool?
|
||||||
public var collapsed: Bool?
|
public var collapsed: Bool?
|
||||||
|
|
||||||
|
|
|
@ -200,8 +200,10 @@
|
||||||
D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC7B232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib */; };
|
D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC7B232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib */; };
|
||||||
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7E2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift */; };
|
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7E2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift */; };
|
||||||
D6A3BC812321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC7F2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.xib */; };
|
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 */; };
|
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */; };
|
||||||
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */; };
|
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 */; };
|
D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCCA2553667800D9DE31 /* FastAccountSwitcherViewController.swift */; };
|
||||||
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */; };
|
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A4DCCB2553667800D9DE31 /* FastAccountSwitcherViewController.xib */; };
|
||||||
D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */; };
|
D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */; };
|
||||||
|
@ -268,10 +270,6 @@
|
||||||
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; };
|
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; };
|
||||||
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
|
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
|
||||||
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; };
|
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; };
|
||||||
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 */; };
|
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; };
|
||||||
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; };
|
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; };
|
||||||
D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; };
|
D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; };
|
||||||
|
@ -564,8 +562,10 @@
|
||||||
D6A3BC7B232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ActionNotificationGroupTableViewCell.xib; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
D6A4DCE425537C7A00D9DE31 /* FastSwitchingAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastSwitchingAccountView.swift; sourceTree = "<group>"; };
|
||||||
|
@ -632,10 +632,6 @@
|
||||||
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = "<group>"; };
|
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = "<group>"; };
|
||||||
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
|
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
|
||||||
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
|
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
|
||||||
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>"; };
|
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>"; };
|
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>"; };
|
D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = "<group>"; };
|
||||||
|
@ -928,7 +924,6 @@
|
||||||
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
|
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
|
||||||
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
|
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
|
||||||
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
|
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
|
||||||
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */,
|
|
||||||
);
|
);
|
||||||
path = Timeline;
|
path = Timeline;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1183,7 +1178,7 @@
|
||||||
D6A3BC822321F69400FD64D5 /* Account List */ = {
|
D6A3BC822321F69400FD64D5 /* Account List */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D6D12B59292D684600D528E1 /* AccountListViewController.swift */,
|
D6A3BC832321F6C100FD64D5 /* AccountListTableViewController.swift */,
|
||||||
);
|
);
|
||||||
path = "Account List";
|
path = "Account List";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1193,7 +1188,6 @@
|
||||||
children = (
|
children = (
|
||||||
D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */,
|
D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */,
|
||||||
D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */,
|
D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */,
|
||||||
D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */,
|
|
||||||
);
|
);
|
||||||
path = "Account Cell";
|
path = "Account Cell";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1201,7 +1195,7 @@
|
||||||
D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */ = {
|
D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */,
|
D6A3BC8D2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift */,
|
||||||
);
|
);
|
||||||
path = "Status Action Account List";
|
path = "Status Action Account List";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1619,7 +1613,7 @@
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
D6D4DDCB212518A000E1C4BB = {
|
D6D4DDCB212518A000E1C4BB = {
|
||||||
CreatedOnToolsVersion = 10.0;
|
CreatedOnToolsVersion = 10.0;
|
||||||
LastSwiftMigration = 1410;
|
LastSwiftMigration = 1200;
|
||||||
};
|
};
|
||||||
D6D4DDDF212518A200E1C4BB = {
|
D6D4DDDF212518A200E1C4BB = {
|
||||||
CreatedOnToolsVersion = 10.0;
|
CreatedOnToolsVersion = 10.0;
|
||||||
|
@ -1838,7 +1832,6 @@
|
||||||
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */,
|
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */,
|
||||||
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
|
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
|
||||||
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */,
|
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */,
|
||||||
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */,
|
|
||||||
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
|
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
|
||||||
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */,
|
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */,
|
||||||
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
|
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
|
||||||
|
@ -1858,6 +1851,7 @@
|
||||||
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */,
|
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */,
|
||||||
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */,
|
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */,
|
||||||
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
|
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
|
||||||
|
D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */,
|
||||||
D64BC18823C1640A000D0238 /* PinStatusActivity.swift in Sources */,
|
D64BC18823C1640A000D0238 /* PinStatusActivity.swift in Sources */,
|
||||||
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */,
|
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */,
|
||||||
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */,
|
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */,
|
||||||
|
@ -1876,6 +1870,7 @@
|
||||||
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
|
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
|
||||||
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
|
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
|
||||||
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
|
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
|
||||||
|
D6A3BC8F2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift in Sources */,
|
||||||
D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */,
|
D6AEBB4823216B1D00E5038B /* AccountActivity.swift in Sources */,
|
||||||
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
|
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
|
||||||
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
|
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
|
||||||
|
@ -1952,7 +1947,6 @@
|
||||||
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
|
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
|
||||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
||||||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
|
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
|
||||||
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */,
|
|
||||||
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
|
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
|
||||||
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
|
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
|
||||||
D627943523A5525100D38C68 /* StatusActivity.swift in Sources */,
|
D627943523A5525100D38C68 /* StatusActivity.swift in Sources */,
|
||||||
|
@ -2035,13 +2029,11 @@
|
||||||
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
||||||
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
|
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
|
||||||
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
|
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
|
||||||
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
|
|
||||||
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */,
|
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */,
|
||||||
D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */,
|
D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */,
|
||||||
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
|
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
|
||||||
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */,
|
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */,
|
||||||
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
|
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
|
||||||
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
|
|
||||||
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
|
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
@ -2195,7 +2187,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 46;
|
CURRENT_PROJECT_VERSION = 45;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2263,7 +2255,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 46;
|
CURRENT_PROJECT_VERSION = 45;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
|
@ -2413,7 +2405,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 46;
|
CURRENT_PROJECT_VERSION = 45;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2442,7 +2434,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 46;
|
CURRENT_PROJECT_VERSION = 45;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = Tusker/Info.plist;
|
INFOPLIST_FILE = Tusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
@ -2552,7 +2544,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 46;
|
CURRENT_PROJECT_VERSION = 45;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
|
@ -2579,7 +2571,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 46;
|
CURRENT_PROJECT_VERSION = 45;
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||||
|
|
|
@ -99,11 +99,6 @@
|
||||||
value = "1"
|
value = "1"
|
||||||
isEnabled = "NO">
|
isEnabled = "NO">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
<EnvironmentVariable
|
|
||||||
key = "CG_CONTEXT_SHOW_BACKTRACE"
|
|
||||||
value = ""
|
|
||||||
isEnabled = "NO">
|
|
||||||
</EnvironmentVariable>
|
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "CG_NUMERICS_SHOW_BACKTRACE"
|
key = "CG_NUMERICS_SHOW_BACKTRACE"
|
||||||
value = ""
|
value = ""
|
||||||
|
|
|
@ -49,7 +49,7 @@ class CreateListService {
|
||||||
do {
|
do {
|
||||||
let request = Client.createList(title: title)
|
let request = Client.createList(title: title)
|
||||||
let (list, _) = try await mastodonController.run(request)
|
let (list, _) = try await mastodonController.run(request)
|
||||||
mastodonController.addedList(list)
|
NotificationCenter.default.post(name: .listsChanged, object: nil)
|
||||||
self.didCreateList?(list)
|
self.didCreateList?(list)
|
||||||
} catch {
|
} catch {
|
||||||
let alert = UIAlertController(title: "Error Creating List", message: error.localizedDescription, preferredStyle: .alert)
|
let alert = UIAlertController(title: "Error Creating List", message: error.localizedDescription, preferredStyle: .alert)
|
||||||
|
@ -64,3 +64,7 @@ class CreateListService {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Foundation.Notification.Name {
|
||||||
|
static let listsChanged = Notification.Name("listsChanged")
|
||||||
|
}
|
||||||
|
|
|
@ -50,7 +50,7 @@ class DeleteListService {
|
||||||
do {
|
do {
|
||||||
let request = List.delete(list)
|
let request = List.delete(list)
|
||||||
_ = try await mastodonController.run(request)
|
_ = try await mastodonController.run(request)
|
||||||
mastodonController.deletedList(list)
|
NotificationCenter.default.post(name: .listsChanged, object: nil)
|
||||||
} catch {
|
} catch {
|
||||||
let alert = UIAlertController(title: "Error Deleting List", message: error.localizedDescription, preferredStyle: .alert)
|
let alert = UIAlertController(title: "Error Deleting List", message: error.localizedDescription, preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||||
|
|
|
@ -11,18 +11,15 @@ import Pachyderm
|
||||||
|
|
||||||
struct InstanceFeatures {
|
struct InstanceFeatures {
|
||||||
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive)
|
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive)
|
||||||
private static let akkomaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; akkoma (.*)\\)", options: .caseInsensitive)
|
|
||||||
|
|
||||||
private var instanceType: InstanceType = .mastodon(.vanilla, nil)
|
private(set) var instanceType = InstanceType.mastodon
|
||||||
|
private(set) var version: Version?
|
||||||
|
private(set) var pleromaVersion: Version?
|
||||||
|
private(set) var hometownVersion: Version?
|
||||||
private(set) var maxStatusChars = 500
|
private(set) var maxStatusChars = 500
|
||||||
|
|
||||||
var localOnlyPosts: Bool {
|
var localOnlyPosts: Bool {
|
||||||
switch instanceType {
|
instanceType == .hometown || instanceType == .glitch
|
||||||
case .mastodon(.hometown(_), _), .mastodon(.glitch, _):
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var mastodonAttachmentRestrictions: Bool {
|
var mastodonAttachmentRestrictions: Bool {
|
||||||
|
@ -30,20 +27,15 @@ struct InstanceFeatures {
|
||||||
}
|
}
|
||||||
|
|
||||||
var pollsAndAttachments: Bool {
|
var pollsAndAttachments: Bool {
|
||||||
instanceType.isPleroma
|
instanceType == .pleroma
|
||||||
}
|
}
|
||||||
|
|
||||||
var boostToOriginalAudience: Bool {
|
var boostToOriginalAudience: Bool {
|
||||||
instanceType.isPleroma || instanceType.isMastodon
|
instanceType == .pleroma || instanceType.isMastodon
|
||||||
}
|
}
|
||||||
|
|
||||||
var profilePinnedStatuses: Bool {
|
var profilePinnedStatuses: Bool {
|
||||||
switch instanceType {
|
instanceType != .pixelfed
|
||||||
case .pixelfed:
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var trends: Bool {
|
var trends: Bool {
|
||||||
|
@ -56,73 +48,45 @@ struct InstanceFeatures {
|
||||||
|
|
||||||
var reblogVisibility: Bool {
|
var reblogVisibility: Bool {
|
||||||
(instanceType.isMastodon && hasVersion(2, 8, 0))
|
(instanceType.isMastodon && hasVersion(2, 8, 0))
|
||||||
|| (instanceType.isPleroma && hasPleromaVersion(2, 0, 0))
|
|| (instanceType == .pleroma && hasPleromaVersion(2, 0, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
var probablySupportsMarkdown: Bool {
|
var probablySupportsMarkdown: Bool {
|
||||||
switch instanceType {
|
instanceType == .pleroma || instanceType == .glitch || instanceType == .hometown
|
||||||
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?) {
|
mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
|
||||||
|
var version: Version?
|
||||||
|
|
||||||
let ver = instance.version.lowercased()
|
let ver = instance.version.lowercased()
|
||||||
if ver.contains("glitch") {
|
if ver.contains("glitch") {
|
||||||
instanceType = .mastodon(.glitch, Version(string: ver))
|
instanceType = .glitch
|
||||||
} else if nodeInfo?.software.name == "hometown" {
|
} else if nodeInfo?.software.name == "hometown" {
|
||||||
var mastoVersion: Version?
|
instanceType = .hometown
|
||||||
var hometownVersion: Version?
|
|
||||||
// like "1.0.6+3.5.2"
|
// like "1.0.6+3.5.2"
|
||||||
let parts = ver.split(separator: "+")
|
let parts = ver.split(separator: "+")
|
||||||
if parts.count == 2 {
|
if parts.count == 2 {
|
||||||
mastoVersion = Version(string: String(parts[1]))
|
version = Version(string: String(parts[1]))
|
||||||
hometownVersion = Version(string: String(parts[0]))
|
hometownVersion = Version(string: String(parts[0]))
|
||||||
} else {
|
|
||||||
mastoVersion = Version(string: ver)
|
|
||||||
}
|
}
|
||||||
instanceType = .mastodon(.hometown(hometownVersion), mastoVersion)
|
|
||||||
} else if ver.contains("pleroma") {
|
} else if ver.contains("pleroma") {
|
||||||
var pleromaVersion: Version?
|
instanceType = .pleroma
|
||||||
if let match = InstanceFeatures.pleromaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) {
|
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)))
|
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") {
|
} else if ver.contains("pixelfed") {
|
||||||
instanceType = .pixelfed
|
instanceType = .pixelfed
|
||||||
} else {
|
} else {
|
||||||
instanceType = .mastodon(.vanilla, Version(string: ver))
|
instanceType = .mastodon
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.version = version ?? Version(string: ver)
|
||||||
|
|
||||||
maxStatusChars = instance.maxStatusCharacters ?? 500
|
maxStatusChars = instance.maxStatusCharacters ?? 500
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
func hasVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
||||||
if case .mastodon(_, .some(let version)) = instanceType {
|
if let version {
|
||||||
return version >= Version(major, minor, patch)
|
return version >= Version(major, minor, patch)
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
|
@ -130,47 +94,30 @@ struct InstanceFeatures {
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
||||||
switch instanceType {
|
if let pleromaVersion {
|
||||||
case .pleroma(.vanilla(.some(let version))), .pleroma(.akkoma(.some(let version))):
|
return pleromaVersion >= Version(major, minor, patch)
|
||||||
return version >= Version(major, minor, patch)
|
} else {
|
||||||
default:
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InstanceFeatures {
|
extension InstanceFeatures {
|
||||||
enum InstanceType {
|
enum InstanceType: Equatable {
|
||||||
case mastodon(MastodonType, Version?)
|
case mastodon // vanilla
|
||||||
case pleroma(PleromaType)
|
case pleroma
|
||||||
|
case hometown
|
||||||
|
case glitch
|
||||||
case pixelfed
|
case pixelfed
|
||||||
|
|
||||||
var isMastodon: Bool {
|
var isMastodon: Bool {
|
||||||
if case .mastodon(_, _) = self {
|
switch self {
|
||||||
|
case .mastodon, .hometown, .glitch:
|
||||||
return true
|
return true
|
||||||
} else {
|
default:
|
||||||
return false
|
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,7 +46,6 @@ class MastodonController: ObservableObject {
|
||||||
@Published private(set) var instance: Instance!
|
@Published private(set) var instance: Instance!
|
||||||
@Published private(set) var nodeInfo: NodeInfo!
|
@Published private(set) var nodeInfo: NodeInfo!
|
||||||
@Published private(set) var instanceFeatures = InstanceFeatures()
|
@Published private(set) var instanceFeatures = InstanceFeatures()
|
||||||
@Published private(set) var lists: [List] = []
|
|
||||||
private(set) var customEmojis: [Emoji]?
|
private(set) var customEmojis: [Emoji]?
|
||||||
|
|
||||||
private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]()
|
private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]()
|
||||||
|
@ -120,15 +119,6 @@ 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) {
|
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
|
||||||
if account != nil {
|
if account != nil {
|
||||||
completion?(.success(account))
|
completion?(.success(account))
|
||||||
|
@ -274,53 +264,4 @@ 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 sensitive = contentWarning != nil
|
||||||
|
|
||||||
let request = Client.createStatus(
|
let request = Client.createStatus(
|
||||||
text: textForPosting(),
|
text: draft.textForPosting(on: mastodonController.instanceFeatures),
|
||||||
contentType: Preferences.shared.statusContentType,
|
contentType: Preferences.shared.statusContentType,
|
||||||
inReplyTo: draft.inReplyToID,
|
inReplyTo: draft.inReplyToID,
|
||||||
media: uploadedAttachments,
|
media: uploadedAttachments,
|
||||||
|
@ -87,7 +87,7 @@ class PostService: ObservableObject {
|
||||||
|
|
||||||
private func getData(for attachment: CompositionAttachment) async throws -> (Data, UTType) {
|
private func getData(for attachment: CompositionAttachment) async throws -> (Data, UTType) {
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
attachment.data.getData(features: mastodonController.instanceFeatures) { result in
|
attachment.data.getData { result in
|
||||||
switch result {
|
switch result {
|
||||||
case let .success(res):
|
case let .success(res):
|
||||||
continuation.resume(returning: res)
|
continuation.resume(returning: res)
|
||||||
|
@ -104,19 +104,6 @@ class PostService: ObservableObject {
|
||||||
return try await mastodonController.run(req).0
|
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 {
|
enum Error: Swift.Error, LocalizedError {
|
||||||
case attachmentData(index: Int, cause: CompositionAttachmentData.Error)
|
case attachmentData(index: Int, cause: CompositionAttachmentData.Error)
|
||||||
case attachmentUpload(index: Int, cause: Client.Error)
|
case attachmentUpload(index: Int, cause: Client.Error)
|
||||||
|
|
|
@ -49,7 +49,7 @@ class RenameListService {
|
||||||
do {
|
do {
|
||||||
let req = List.update(list, title: title)
|
let req = List.update(list, title: title)
|
||||||
let (list, _) = try await mastodonController.run(req)
|
let (list, _) = try await mastodonController.run(req)
|
||||||
mastodonController.renamedList(list)
|
NotificationCenter.default.post(name: .listRenamed, object: list.id, userInfo: ["list": list])
|
||||||
} catch {
|
} catch {
|
||||||
let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert)
|
let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||||
|
@ -63,3 +63,7 @@ class RenameListService {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Foundation.Notification.Name {
|
||||||
|
static let listRenamed = Notification.Name("listRenamed")
|
||||||
|
}
|
||||||
|
|
|
@ -51,7 +51,7 @@ enum CompositionAttachmentData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
|
func getData(completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
|
||||||
switch self {
|
switch self {
|
||||||
case let .image(image):
|
case let .image(image):
|
||||||
// Export as JPEG instead of PNG, otherweise photos straight from the camera are too large
|
// Export as JPEG instead of PNG, otherweise photos straight from the camera are too large
|
||||||
|
@ -71,26 +71,21 @@ enum CompositionAttachmentData {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !skipAllConversion else {
|
|
||||||
completion(.success((data, UTType(dataUTI)!)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let utType: UTType
|
let utType: UTType
|
||||||
let image = CIImage(data: data)!
|
let image = CIImage(data: data)!
|
||||||
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
|
let needsColorSpaceConversion = image.colorSpace?.name != CGColorSpace.sRGB
|
||||||
|
|
||||||
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
|
// 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)
|
// 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 that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
|
||||||
if needsColorSpaceConversion || dataUTI == "public.heic" {
|
if needsColorSpaceConversion || dataUTI == "public.heic" {
|
||||||
let context = CIContext()
|
let context = CIContext()
|
||||||
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
|
let sRGB = CGColorSpace(name: CGColorSpace.sRGB)!
|
||||||
if dataUTI == "public.png" {
|
if dataUTI == "public.png" {
|
||||||
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)!
|
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: sRGB)!
|
||||||
utType = .png
|
utType = .png
|
||||||
} else {
|
} else {
|
||||||
data = context.jpegRepresentation(of: image, colorSpace: colorSpace)!
|
data = context.jpegRepresentation(of: image, colorSpace: sRGB)!
|
||||||
utType = .jpeg
|
utType = .jpeg
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -86,6 +86,19 @@ class Draft: Codable, ObservableObject {
|
||||||
|
|
||||||
try container.encode(initialText, forKey: .initialText)
|
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 {
|
extension Draft: Equatable {
|
||||||
|
|
|
@ -50,7 +50,6 @@ class Preferences: Codable, ObservableObject {
|
||||||
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
|
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
|
||||||
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
|
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
|
||||||
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
|
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.blurAllMedia = try container.decode(Bool.self, forKey: .blurAllMedia)
|
||||||
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
|
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
|
||||||
|
@ -92,7 +91,6 @@ class Preferences: Codable, ObservableObject {
|
||||||
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
|
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
|
||||||
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
|
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
|
||||||
try container.encode(mentionReblogger, forKey: .mentionReblogger)
|
try container.encode(mentionReblogger, forKey: .mentionReblogger)
|
||||||
try container.encode(useTwitterKeyboard, forKey: .useTwitterKeyboard)
|
|
||||||
|
|
||||||
try container.encode(blurAllMedia, forKey: .blurAllMedia)
|
try container.encode(blurAllMedia, forKey: .blurAllMedia)
|
||||||
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
|
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
|
||||||
|
@ -133,7 +131,6 @@ class Preferences: Codable, ObservableObject {
|
||||||
@Published var requireAttachmentDescriptions = false
|
@Published var requireAttachmentDescriptions = false
|
||||||
@Published var contentWarningCopyMode = ContentWarningCopyMode.asIs
|
@Published var contentWarningCopyMode = ContentWarningCopyMode.asIs
|
||||||
@Published var mentionReblogger = false
|
@Published var mentionReblogger = false
|
||||||
@Published var useTwitterKeyboard = false
|
|
||||||
|
|
||||||
// MARK: Media
|
// MARK: Media
|
||||||
@Published var blurAllMedia = false {
|
@Published var blurAllMedia = false {
|
||||||
|
@ -184,7 +181,6 @@ class Preferences: Codable, ObservableObject {
|
||||||
case requireAttachmentDescriptions
|
case requireAttachmentDescriptions
|
||||||
case contentWarningCopyMode
|
case contentWarningCopyMode
|
||||||
case mentionReblogger
|
case mentionReblogger
|
||||||
case useTwitterKeyboard
|
|
||||||
|
|
||||||
case blurAllMedia
|
case blurAllMedia
|
||||||
case blurMediaBehindContentWarning
|
case blurMediaBehindContentWarning
|
||||||
|
|
|
@ -42,9 +42,9 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
let controller = MastodonController.getForAccount(account)
|
let controller = MastodonController.getForAccount(account)
|
||||||
session.mastodonController = controller
|
session.mastodonController = controller
|
||||||
Task {
|
|
||||||
try? await controller.initialize()
|
controller.getOwnAccount()
|
||||||
}
|
controller.getOwnInstance()
|
||||||
|
|
||||||
guard let rootVC = viewController(for: activity, mastodonController: controller) else {
|
guard let rootVC = viewController(for: activity, mastodonController: controller) else {
|
||||||
UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil)
|
UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil)
|
||||||
|
|
|
@ -50,9 +50,8 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
session.mastodonController = controller
|
session.mastodonController = controller
|
||||||
Task {
|
controller.getOwnAccount()
|
||||||
try? await controller.initialize()
|
controller.getOwnInstance()
|
||||||
}
|
|
||||||
|
|
||||||
let composeVC = ComposeHostingController(draft: draft, mastodonController: controller)
|
let composeVC = ComposeHostingController(draft: draft, mastodonController: controller)
|
||||||
composeVC.delegate = self
|
composeVC.delegate = self
|
||||||
|
|
|
@ -95,15 +95,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
}
|
}
|
||||||
|
|
||||||
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
|
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
|
||||||
if let mastodonController = window?.windowScene?.session.mastodonController {
|
if let mastodonController = window?.windowScene?.session.mastodonController {
|
||||||
if let vcActivity = rootViewController?.stateRestorationActivity() {
|
return UserActivityManager.mainSceneActivity(accountID: mastodonController.accountInfo!.id)
|
||||||
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 {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -151,6 +144,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func showAppOrOnboardingUI(session: UISceneSession? = nil) {
|
func showAppOrOnboardingUI(session: UISceneSession? = nil) {
|
||||||
let session = session ?? window!.windowScene!.session
|
let session = session ?? window!.windowScene!.session
|
||||||
if LocalData.shared.onboardingComplete {
|
if LocalData.shared.onboardingComplete {
|
||||||
|
@ -168,12 +162,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
|
|
||||||
activateAccount(account, animated: false)
|
activateAccount(account, animated: false)
|
||||||
|
|
||||||
if let activity = launchActivity {
|
if let activity = launchActivity,
|
||||||
if activity.isStateRestorationActivity {
|
activity.activityType != UserActivityType.mainScene.rawValue {
|
||||||
rootViewController?.restoreActivity(activity)
|
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!))
|
||||||
} else if activity.activityType != UserActivityType.mainScene.rawValue {
|
|
||||||
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window!.rootViewController = createOnboardingUI()
|
window!.rootViewController = createOnboardingUI()
|
||||||
|
@ -212,9 +203,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
|
|
||||||
func createAppUI() -> TuskerRootViewController {
|
func createAppUI() -> TuskerRootViewController {
|
||||||
let mastodonController = window!.windowScene!.session.mastodonController!
|
let mastodonController = window!.windowScene!.session.mastodonController!
|
||||||
Task {
|
mastodonController.getOwnAccount()
|
||||||
try? await mastodonController.initialize()
|
mastodonController.getOwnInstance()
|
||||||
}
|
|
||||||
|
|
||||||
let split = MainSplitViewController(mastodonController: mastodonController)
|
let split = MainSplitViewController(mastodonController: mastodonController)
|
||||||
if UIDevice.current.userInterfaceIdiom == .phone,
|
if UIDevice.current.userInterfaceIdiom == .phone,
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
}
|
|
@ -1,120 +0,0 @@
|
||||||
//
|
|
||||||
// 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,39 +46,30 @@ class AssetCollectionsListViewController: UITableViewController {
|
||||||
})
|
})
|
||||||
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
snapshot.appendSections([.system, .albums, .sharedAlbums, .smartAlbums])
|
snapshot.appendSections([.system, .albums, .smartAlbums])
|
||||||
snapshot.appendItems([.cameraRoll], toSection: .system)
|
snapshot.appendItems([.cameraRoll], toSection: .system)
|
||||||
|
|
||||||
let smartAlbums = PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .any, options: nil)
|
let smartAlbums = PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .any, options: nil)
|
||||||
var smartAlbumItems = [Item]()
|
var smartAlbumItems = [Item]()
|
||||||
smartAlbums.enumerateObjects { (collection, _, _) in
|
smartAlbums.enumerateObjects { (collection, _, _) in
|
||||||
guard collection.assetCollectionSubtype != .smartAlbumAllHidden else {
|
guard collection.assetCollectionSubtype != .smartAlbumAllHidden && collection.assetCollectionSubtype != .smartAlbumRecentlyAdded else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
smartAlbumItems.append(.album(collection))
|
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)
|
snapshot.appendItems(smartAlbumItems, toSection: .smartAlbums)
|
||||||
|
|
||||||
let albums = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .any, options: nil)
|
let albums = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .any, options: nil)
|
||||||
var albumItems = [Item]()
|
var albumItems = [Item]()
|
||||||
var sharedItems = [Item]()
|
|
||||||
albums.enumerateObjects { (collection, _, _) in
|
albums.enumerateObjects { (collection, _, _) in
|
||||||
if collection.estimatedAssetCount > 0 {
|
if collection.estimatedAssetCount > 0 {
|
||||||
if collection.assetCollectionSubtype == .albumCloudShared {
|
albumItems.append(.album(collection))
|
||||||
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(albumItems, toSection: .albums)
|
||||||
snapshot.appendItems(sharedItems, toSection: .sharedAlbums)
|
|
||||||
|
|
||||||
dataSource.apply(snapshot, animatingDifferences: false)
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Table view delegate
|
// MARK: - Table view delegate
|
||||||
|
@ -112,7 +103,6 @@ extension AssetCollectionsListViewController {
|
||||||
enum Section {
|
enum Section {
|
||||||
case system
|
case system
|
||||||
case albums
|
case albums
|
||||||
case sharedAlbums
|
|
||||||
case smartAlbums
|
case smartAlbums
|
||||||
}
|
}
|
||||||
enum Item: Hashable {
|
enum Item: Hashable {
|
||||||
|
@ -128,26 +118,15 @@ extension AssetCollectionsListViewController {
|
||||||
hasher.combine(collection.localIdentifier)
|
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> {
|
class DataSource: UITableViewDiffableDataSource<Section, Item> {
|
||||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
switch sectionIdentifier(for: section) {
|
let currentSnapshot = snapshot()
|
||||||
case .albums:
|
if currentSnapshot.indexOfSection(.albums) == section {
|
||||||
return NSLocalizedString("Albums", comment: "albums section title")
|
return NSLocalizedString("Albums", comment: "albums section title")
|
||||||
case .sharedAlbums:
|
} else if currentSnapshot.indexOfSection(.smartAlbums) == section {
|
||||||
return NSLocalizedString("Shared Albums", comment: "shared albums section title")
|
|
||||||
case .smartAlbums:
|
|
||||||
return NSLocalizedString("Smart Albums", comment: "smart albums section title")
|
return NSLocalizedString("Smart Albums", comment: "smart albums section title")
|
||||||
default:
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,6 @@ struct ComposeAttachmentRow: View {
|
||||||
@ObservedObject var draft: Draft
|
@ObservedObject var draft: Draft
|
||||||
@ObservedObject var attachment: CompositionAttachment
|
@ObservedObject var attachment: CompositionAttachment
|
||||||
|
|
||||||
@EnvironmentObject var mastodonController: MastodonController
|
|
||||||
@EnvironmentObject var uiState: ComposeUIState
|
@EnvironmentObject var uiState: ComposeUIState
|
||||||
@State private var mode: Mode = .allowEntry
|
@State private var mode: Mode = .allowEntry
|
||||||
@State private var isShowingTextRecognitionFailedAlert = false
|
@State private var isShowingTextRecognitionFailedAlert = false
|
||||||
|
@ -91,7 +90,7 @@ struct ComposeAttachmentRow: View {
|
||||||
mode = .recognizingText
|
mode = .recognizingText
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
self.attachment.data.getData(features: mastodonController.instanceFeatures, skipAllConversion: true) { (result) in
|
self.attachment.data.getData { (result) in
|
||||||
let data: Data
|
let data: Data
|
||||||
do {
|
do {
|
||||||
try data = result.get().0
|
try data = result.get().0
|
||||||
|
|
|
@ -51,20 +51,9 @@ struct ComposePollView: View {
|
||||||
.hoverEffect()
|
.hoverEffect()
|
||||||
}
|
}
|
||||||
|
|
||||||
List {
|
ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in
|
||||||
ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in
|
ComposePollOption(poll: poll, option: e.element, optionIndex: e.offset)
|
||||||
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) {
|
Button(action: self.addOption) {
|
||||||
Label("Add Option", systemImage: "plus")
|
Label("Add Option", systemImage: "plus")
|
||||||
|
|
|
@ -42,20 +42,18 @@ import Combine
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ComposeView: View {
|
struct ComposeView: View {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
@ObservedObject var mastodonController: MastodonController
|
@ObservedObject var mastodonController: MastodonController
|
||||||
@ObservedObject var uiState: ComposeUIState
|
@ObservedObject var uiState: ComposeUIState
|
||||||
var draft: Draft {
|
|
||||||
uiState.draft
|
|
||||||
}
|
|
||||||
|
|
||||||
@State private var globalFrameOutsideList: CGRect = .zero
|
@State private var globalFrameOutsideList: CGRect = .zero
|
||||||
@State private var contentWarningBecomeFirstResponder = false
|
@State private var contentWarningBecomeFirstResponder = false
|
||||||
@State private var mainComposeTextViewBecomeFirstResponder = false
|
@State private var mainComposeTextViewBecomeFirstResponder = false
|
||||||
@StateObject private var keyboardReader = KeyboardReader()
|
|
||||||
|
|
||||||
@OptionalStateObject private var poster: PostService?
|
@OptionalStateObject private var poster: PostService?
|
||||||
@State private var isShowingPostErrorAlert = false
|
@State private var isShowingPostErrorAlert = false
|
||||||
@State private var postError: PostService.Error?
|
@State private var postError: PostService.Error?
|
||||||
|
|
||||||
private var isPosting: Bool {
|
private var isPosting: Bool {
|
||||||
poster != nil
|
poster != nil
|
||||||
}
|
}
|
||||||
|
@ -63,6 +61,7 @@ struct ComposeView: View {
|
||||||
private let stackPadding: CGFloat = 8
|
private let stackPadding: CGFloat = 8
|
||||||
|
|
||||||
init(mastodonController: MastodonController, uiState: ComposeUIState) {
|
init(mastodonController: MastodonController, uiState: ComposeUIState) {
|
||||||
|
self.draft = uiState.draft
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
self.uiState = uiState
|
self.uiState = uiState
|
||||||
}
|
}
|
||||||
|
@ -108,8 +107,6 @@ struct ComposeView: View {
|
||||||
|
|
||||||
ComposeToolbar(draft: draft)
|
ComposeToolbar(draft: draft)
|
||||||
}
|
}
|
||||||
// on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
|
|
||||||
.padding(.bottom, keyboardInset)
|
|
||||||
.transition(.move(edge: .bottom))
|
.transition(.move(edge: .bottom))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -138,17 +135,6 @@ 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
|
@ViewBuilder
|
||||||
private var autocompleteSuggestions: some View {
|
private var autocompleteSuggestions: some View {
|
||||||
if let state = uiState.autocompleteState {
|
if let state = uiState.autocompleteState {
|
||||||
|
@ -175,7 +161,7 @@ struct ComposeView: View {
|
||||||
|
|
||||||
if draft.contentWarningEnabled {
|
if draft.contentWarningEnabled {
|
||||||
ComposeEmojiTextField(
|
ComposeEmojiTextField(
|
||||||
text: $uiState.draft.contentWarning,
|
text: $draft.contentWarning,
|
||||||
placeholder: "Write your warning here",
|
placeholder: "Write your warning here",
|
||||||
becomeFirstResponder: $contentWarningBecomeFirstResponder,
|
becomeFirstResponder: $contentWarningBecomeFirstResponder,
|
||||||
focusNextView: $mainComposeTextViewBecomeFirstResponder
|
focusNextView: $mainComposeTextViewBecomeFirstResponder
|
||||||
|
@ -330,26 +316,6 @@ 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 {
|
//struct ComposeView_Previews: PreviewProvider {
|
||||||
// static var previews: some View {
|
// static var previews: some View {
|
||||||
// ComposeView()
|
// ComposeView()
|
||||||
|
|
|
@ -79,7 +79,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
||||||
|
|
||||||
@EnvironmentObject var uiState: ComposeUIState
|
@EnvironmentObject var uiState: ComposeUIState
|
||||||
@EnvironmentObject var mastodonController: MastodonController
|
@EnvironmentObject var mastodonController: MastodonController
|
||||||
@ObservedObject var preferences = Preferences.shared
|
|
||||||
@Environment(\.isEnabled) var isEnabled: Bool
|
@Environment(\.isEnabled) var isEnabled: Bool
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UITextView {
|
func makeUIView(context: Context) -> UITextView {
|
||||||
|
@ -102,7 +101,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
uiView.isEditable = isEnabled
|
uiView.isEditable = isEnabled
|
||||||
uiView.keyboardType = preferences.useTwitterKeyboard ? .twitter : .default
|
|
||||||
|
|
||||||
context.coordinator.text = $text
|
context.coordinator.text = $text
|
||||||
context.coordinator.didChange = textDidChange
|
context.coordinator.didChange = textDidChange
|
||||||
|
|
|
@ -24,8 +24,6 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
|
|
||||||
var searchControllerStatusOnAppearance: Bool? = nil
|
var searchControllerStatusOnAppearance: Bool? = nil
|
||||||
|
|
||||||
private var listsCancellable: AnyCancellable?
|
|
||||||
|
|
||||||
init(mastodonController: MastodonController) {
|
init(mastodonController: MastodonController) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
|
||||||
|
@ -72,10 +70,9 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil)
|
||||||
|
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)
|
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) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
@ -144,7 +141,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
snapshot.appendSections(Section.allCases.filter { $0 != .discover })
|
snapshot.appendSections(Section.allCases.filter { $0 != .discover })
|
||||||
snapshot.appendItems([.bookmarks], toSection: .bookmarks)
|
snapshot.appendItems([.bookmarks], toSection: .bookmarks)
|
||||||
if mastodonController.instanceFeatures.trends,
|
if mastodonController.instanceFeatures.instanceType.isMastodon,
|
||||||
!Preferences.shared.hideDiscover {
|
!Preferences.shared.hideDiscover {
|
||||||
addDiscoverSection(to: &snapshot)
|
addDiscoverSection(to: &snapshot)
|
||||||
}
|
}
|
||||||
|
@ -161,7 +158,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
snapshot.appendItems([.findInstance], toSection: .savedInstances)
|
snapshot.appendItems([.findInstance], toSection: .savedInstances)
|
||||||
dataSource.apply(snapshot, animatingDifferences: false)
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
|
||||||
reloadLists(mastodonController.lists)
|
reloadLists()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addDiscoverSection(to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
|
private func addDiscoverSection(to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
|
||||||
|
@ -175,7 +172,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
|
|
||||||
private func ownInstanceLoaded(_ instance: Instance) {
|
private func ownInstanceLoaded(_ instance: Instance) {
|
||||||
var snapshot = self.dataSource.snapshot()
|
var snapshot = self.dataSource.snapshot()
|
||||||
if mastodonController.instanceFeatures.trends,
|
if mastodonController.instanceFeatures.instanceType.isMastodon,
|
||||||
!snapshot.sectionIdentifiers.contains(.discover) {
|
!snapshot.sectionIdentifiers.contains(.discover) {
|
||||||
snapshot.insertSections([.discover], afterSection: .bookmarks)
|
snapshot.insertSections([.discover], afterSection: .bookmarks)
|
||||||
snapshot.appendItems([.trendingTags, .profileDirectory], toSection: .discover)
|
snapshot.appendItems([.trendingTags, .profileDirectory], toSection: .discover)
|
||||||
|
@ -183,13 +180,39 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
||||||
self.dataSource.apply(snapshot)
|
self.dataSource.apply(snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reloadLists(_ lists: [List]) {
|
@objc private func reloadLists() {
|
||||||
var snapshot = self.dataSource.snapshot()
|
let request = Client.getLists()
|
||||||
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .lists))
|
mastodonController.run(request) { (response) in
|
||||||
snapshot.appendItems(lists.map { .list($0) }, toSection: .lists)
|
guard case let .success(lists, _) = response else {
|
||||||
snapshot.appendItems([.addList], toSection: .lists)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
self.dataSource.apply(snapshot)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|
|
@ -97,7 +97,7 @@ class TrendingStatusesViewController: UIViewController {
|
||||||
do {
|
do {
|
||||||
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
|
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
|
||||||
} catch {
|
} catch {
|
||||||
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
await dataSource.apply(snapshot)
|
await dataSource.apply(snapshot)
|
||||||
let config = ToastConfiguration(from: error, with: "Loading Trending Posts", in: self) { toast in
|
let config = ToastConfiguration(from: error, with: "Loading Trending Posts", in: self) { toast in
|
||||||
toast.dismissToast(animated: true)
|
toast.dismissToast(animated: true)
|
||||||
|
@ -122,27 +122,6 @@ extension TrendingStatusesViewController {
|
||||||
case status(id: String, state: StatusState)
|
case status(id: String, state: StatusState)
|
||||||
case loadingIndicator
|
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 {
|
var hideSeparators: Bool {
|
||||||
if case .loadingIndicator = self {
|
if case .loadingIndicator = self {
|
||||||
return true
|
return true
|
||||||
|
|
|
@ -14,7 +14,6 @@ import VisionKit
|
||||||
protocol LargeImageContentView: UIView {
|
protocol LargeImageContentView: UIView {
|
||||||
var animationImage: UIImage? { get }
|
var animationImage: UIImage? { get }
|
||||||
var activityItemsForSharing: [Any] { get }
|
var activityItemsForSharing: [Any] { get }
|
||||||
var owner: LargeImageViewController? { get set }
|
|
||||||
func setControlsVisible(_ controlsVisible: Bool)
|
func setControlsVisible(_ controlsVisible: Bool)
|
||||||
func grayscaleStateChanged()
|
func grayscaleStateChanged()
|
||||||
}
|
}
|
||||||
|
@ -30,14 +29,17 @@ class LargeImageImageContentView: UIImageView, LargeImageContentView {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
var animationImage: UIImage? { image! }
|
var animationImage: UIImage? { image! }
|
||||||
|
|
||||||
var activityItemsForSharing: [Any] {
|
var activityItemsForSharing: [Any] {
|
||||||
[image!]
|
[image!]
|
||||||
}
|
}
|
||||||
weak var owner: LargeImageViewController?
|
|
||||||
|
|
||||||
private var sourceData: Data?
|
private var sourceData: Data?
|
||||||
|
private weak var owner: UIViewController?
|
||||||
|
|
||||||
|
init(image: UIImage, owner: UIViewController?) {
|
||||||
|
self.owner = owner
|
||||||
|
|
||||||
init(image: UIImage) {
|
|
||||||
super.init(image: image)
|
super.init(image: image)
|
||||||
|
|
||||||
contentMode = .scaleAspectFit
|
contentMode = .scaleAspectFit
|
||||||
|
@ -107,11 +109,11 @@ extension LargeImageImageContentView: ImageAnalysisInteractionDelegate {
|
||||||
|
|
||||||
class LargeImageGifContentView: GIFImageView, LargeImageContentView {
|
class LargeImageGifContentView: GIFImageView, LargeImageContentView {
|
||||||
var animationImage: UIImage? { image }
|
var animationImage: UIImage? { image }
|
||||||
|
|
||||||
var activityItemsForSharing: [Any] {
|
var activityItemsForSharing: [Any] {
|
||||||
// todo: should gifs share the data?
|
// todo: should gifs share the data?
|
||||||
[image].compactMap { $0 }
|
[image].compactMap { $0 }
|
||||||
}
|
}
|
||||||
weak var owner: LargeImageViewController?
|
|
||||||
|
|
||||||
init(gifController: GIFController) {
|
init(gifController: GIFController) {
|
||||||
super.init(image: gifController.lastFrame?.image)
|
super.init(image: gifController.lastFrame?.image)
|
||||||
|
@ -142,7 +144,6 @@ 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?
|
// 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
|
private let asset: AVURLAsset
|
||||||
|
|
||||||
|
|
|
@ -17,16 +17,17 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
|
|
||||||
@IBOutlet weak var scrollView: UIScrollView!
|
@IBOutlet weak var scrollView: UIScrollView!
|
||||||
@IBOutlet weak var topControlsView: UIView!
|
@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 bottomControlsView: UIView!
|
||||||
@IBOutlet weak var descriptionLabel: UILabel!
|
@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 {
|
var contentView: LargeImageContentView {
|
||||||
didSet {
|
didSet {
|
||||||
oldValue.removeFromSuperview()
|
oldValue.removeFromSuperview()
|
||||||
|
@ -85,13 +86,9 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
setupContentView()
|
setupContentView()
|
||||||
setupControls()
|
|
||||||
|
|
||||||
setControlsVisible(initialControlsVisible, animated: false)
|
setControlsVisible(initialControlsVisible, animated: false)
|
||||||
if contentView.activityItemsForSharing.isEmpty {
|
shareButton.isEnabled = !contentView.activityItemsForSharing.isEmpty
|
||||||
shareContainer.isUserInteractionEnabled = false
|
|
||||||
shareImage.tintColor = .systemGray
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollView.delegate = self
|
scrollView.delegate = self
|
||||||
|
|
||||||
|
@ -106,19 +103,15 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
dismissInteractionController = LargeImageInteractionController(viewController: self)
|
dismissInteractionController = LargeImageInteractionController(viewController: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
let singleTap = UITapGestureRecognizer(target: self, action: #selector(scrollViewPressed(_:)))
|
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(scrollViewPressed(_:))))
|
||||||
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(scrollViewDoubleTapped(_:)))
|
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(scrollViewDoubleTapped(_:)))
|
||||||
doubleTap.numberOfTapsRequired = 2
|
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)
|
view.addGestureRecognizer(doubleTap)
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupContentView() {
|
private func setupContentView() {
|
||||||
contentView.owner = self
|
|
||||||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
scrollView.addSubview(contentView)
|
scrollView.addSubview(contentView)
|
||||||
contentViewLeadingConstraint = contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
|
contentViewLeadingConstraint = contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
|
||||||
|
@ -129,62 +122,6 @@ 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() {
|
override func viewDidLayoutSubviews() {
|
||||||
super.viewDidLayoutSubviews()
|
super.viewDidLayoutSubviews()
|
||||||
|
|
||||||
|
@ -215,7 +152,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
// since the corner radius didn't change
|
// since the corner radius didn't change
|
||||||
let notchWidth: CGFloat = 210
|
let notchWidth: CGFloat = 210
|
||||||
let earWidth = (view.bounds.width - notchWidth) / 2
|
let earWidth = (view.bounds.width - notchWidth) / 2
|
||||||
let offset = (earWidth - shareImage.bounds.width) / 2
|
let offset = (earWidth - shareButton.bounds.width) / 2
|
||||||
shareButtonLeadingConstraint.constant = offset
|
shareButtonLeadingConstraint.constant = offset
|
||||||
closeButtonTrailingConstraint.constant = offset
|
closeButtonTrailingConstraint.constant = offset
|
||||||
} else if pillDeviceTopInsets.contains(view.safeAreaInsets.top) {
|
} else if pillDeviceTopInsets.contains(view.safeAreaInsets.top) {
|
||||||
|
@ -334,7 +271,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
||||||
|
|
||||||
@IBAction func sharePressed(_ sender: Any) {
|
@IBAction func sharePressed(_ sender: Any) {
|
||||||
let activityVC = UIActivityViewController(activityItems: contentView.activityItemsForSharing, applicationActivities: nil)
|
let activityVC = UIActivityViewController(activityItems: contentView.activityItemsForSharing, applicationActivities: nil)
|
||||||
activityVC.popoverPresentationController?.sourceView = shareImage
|
activityVC.popoverPresentationController?.sourceView = shareButton
|
||||||
present(activityVC, animated: true)
|
present(activityVC, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,8 +11,15 @@
|
||||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="LargeImageViewController" customModule="Tusker" customModuleProvider="target">
|
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="LargeImageViewController" customModule="Tusker" customModuleProvider="target">
|
||||||
<connections>
|
<connections>
|
||||||
<outlet property="bottomControlsView" destination="rPa-Zu-T6g" id="Rgz-AQ-9nt"/>
|
<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="descriptionLabel" destination="eo5-fc-RV8" id="vrW-RJ-y5k"/>
|
||||||
<outlet property="scrollView" destination="Skj-xq-AgQ" id="TFb-zF-m1b"/>
|
<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="topControlsView" destination="kHo-B9-R7a" id="8sJ-xQ-7ix"/>
|
||||||
<outlet property="view" destination="BJw-5C-9nT" id="1C2-VA-mNf"/>
|
<outlet property="view" destination="BJw-5C-9nT" id="1C2-VA-mNf"/>
|
||||||
</connections>
|
</connections>
|
||||||
|
@ -26,8 +33,45 @@
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||||
<gestureRecognizers/>
|
<gestureRecognizers/>
|
||||||
</scrollView>
|
</scrollView>
|
||||||
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kHo-B9-R7a">
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="kHo-B9-R7a">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="36"/>
|
<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>
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rPa-Zu-T6g">
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rPa-Zu-T6g">
|
||||||
<rect key="frame" x="0.0" y="622.5" width="375" height="44.5"/>
|
<rect key="frame" x="0.0" y="622.5" width="375" height="44.5"/>
|
||||||
|
@ -66,4 +110,8 @@
|
||||||
<point key="canvasLocation" x="-164" y="476"/>
|
<point key="canvasLocation" x="-164" y="476"/>
|
||||||
</view>
|
</view>
|
||||||
</objects>
|
</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>
|
</document>
|
||||||
|
|
|
@ -138,9 +138,9 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
|
||||||
content = LargeImageGifContentView(gifController: gifController)
|
content = LargeImageGifContentView(gifController: gifController)
|
||||||
} else {
|
} else {
|
||||||
if let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) {
|
if let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) {
|
||||||
content = LargeImageImageContentView(image: transformedImage)
|
content = LargeImageImageContentView(image: transformedImage, owner: self)
|
||||||
} else {
|
} else {
|
||||||
content = LargeImageImageContentView(image: image)
|
content = LargeImageImageContentView(image: image, owner: self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -167,7 +167,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
|
||||||
let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: source) {
|
let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: source) {
|
||||||
image = grayscale
|
image = grayscale
|
||||||
}
|
}
|
||||||
setContent(LargeImageImageContentView(image: image))
|
setContent(LargeImageImageContentView(image: image, owner: self))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import Combine
|
|
||||||
|
|
||||||
class EditListAccountsViewController: EnhancedTableViewController {
|
class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
|
|
||||||
|
@ -23,8 +22,6 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
var searchResultsController: EditListSearchResultsContainerViewController!
|
var searchResultsController: EditListSearchResultsContainerViewController!
|
||||||
var searchController: UISearchController!
|
var searchController: UISearchController!
|
||||||
|
|
||||||
private var listRenamedCancellable: AnyCancellable?
|
|
||||||
|
|
||||||
init(list: List, mastodonController: MastodonController) {
|
init(list: List, mastodonController: MastodonController) {
|
||||||
self.list = list
|
self.list = list
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
@ -33,13 +30,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
|
|
||||||
listChanged()
|
listChanged()
|
||||||
|
|
||||||
listRenamedCancellable = mastodonController.$lists
|
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: list.id)
|
||||||
.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) {
|
required init?(coder: NSCoder) {
|
||||||
|
@ -97,6 +88,12 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
title = String(format: NSLocalizedString("Edit %@", comment: "edit list screen title"), list.title)
|
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 {
|
func loadAccounts() async {
|
||||||
do {
|
do {
|
||||||
let request = List.getAccounts(list)
|
let request = List.getAccounts(list)
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import Combine
|
|
||||||
|
|
||||||
class ListTimelineViewController: TimelineViewController {
|
class ListTimelineViewController: TimelineViewController {
|
||||||
|
|
||||||
|
@ -16,8 +15,6 @@ class ListTimelineViewController: TimelineViewController {
|
||||||
|
|
||||||
var presentEditOnAppear = false
|
var presentEditOnAppear = false
|
||||||
|
|
||||||
private var listRenamedCancellable: AnyCancellable?
|
|
||||||
|
|
||||||
init(for list: List, mastodonController: MastodonController) {
|
init(for list: List, mastodonController: MastodonController) {
|
||||||
self.list = list
|
self.list = list
|
||||||
|
|
||||||
|
@ -25,13 +22,7 @@ class ListTimelineViewController: TimelineViewController {
|
||||||
|
|
||||||
listChanged()
|
listChanged()
|
||||||
|
|
||||||
listRenamedCancellable = mastodonController.$lists
|
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: list.id)
|
||||||
.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) {
|
required init?(coder aDecoder: NSCoder) {
|
||||||
|
@ -49,7 +40,6 @@ class ListTimelineViewController: TimelineViewController {
|
||||||
|
|
||||||
if presentEditOnAppear {
|
if presentEditOnAppear {
|
||||||
presentEdit(animated: animated)
|
presentEdit(animated: animated)
|
||||||
presentEditOnAppear = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,6 +47,12 @@ class ListTimelineViewController: TimelineViewController {
|
||||||
title = list.title
|
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) {
|
func presentEdit(animated: Bool) {
|
||||||
let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController)
|
let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController)
|
||||||
editListAccountsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed))
|
editListAccountsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed))
|
||||||
|
|
|
@ -87,16 +87,6 @@ extension AccountSwitchingContainerViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AccountSwitchingContainerViewController: TuskerRootViewController {
|
extension AccountSwitchingContainerViewController: TuskerRootViewController {
|
||||||
func stateRestorationActivity() -> NSUserActivity? {
|
|
||||||
loadViewIfNeeded()
|
|
||||||
return root.stateRestorationActivity()
|
|
||||||
}
|
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
|
||||||
loadViewIfNeeded()
|
|
||||||
root.restoreActivity(activity)
|
|
||||||
}
|
|
||||||
|
|
||||||
func presentCompose() {
|
func presentCompose() {
|
||||||
loadViewIfNeeded()
|
loadViewIfNeeded()
|
||||||
root.presentCompose()
|
root.presentCompose()
|
||||||
|
|
|
@ -11,14 +11,6 @@ import Duckable
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
extension DuckableContainerViewController: TuskerRootViewController {
|
extension DuckableContainerViewController: TuskerRootViewController {
|
||||||
func stateRestorationActivity() -> NSUserActivity? {
|
|
||||||
(child as? TuskerRootViewController)?.stateRestorationActivity()
|
|
||||||
}
|
|
||||||
|
|
||||||
func restoreActivity(_ activity: NSUserActivity) {
|
|
||||||
(child as? TuskerRootViewController)?.restoreActivity(activity)
|
|
||||||
}
|
|
||||||
|
|
||||||
func presentCompose() {
|
func presentCompose() {
|
||||||
(child as? TuskerRootViewController)?.presentCompose()
|
(child as? TuskerRootViewController)?.presentCompose()
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import Combine
|
|
||||||
|
|
||||||
protocol MainSidebarViewControllerDelegate: AnyObject {
|
protocol MainSidebarViewControllerDelegate: AnyObject {
|
||||||
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
|
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
|
||||||
|
@ -29,8 +28,6 @@ class MainSidebarViewController: UIViewController {
|
||||||
private var collectionView: UICollectionView!
|
private var collectionView: UICollectionView!
|
||||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
|
||||||
private var listsCancellable: AnyCancellable?
|
|
||||||
|
|
||||||
var allItems: [Item] {
|
var allItems: [Item] {
|
||||||
[
|
[
|
||||||
.tab(.timelines),
|
.tab(.timelines),
|
||||||
|
@ -102,11 +99,10 @@ class MainSidebarViewController: UIViewController {
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil)
|
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(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)
|
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||||
|
|
||||||
listsCancellable = mastodonController.$lists
|
|
||||||
.sink { [unowned self] in self.reloadLists($0) }
|
|
||||||
|
|
||||||
onViewDidLoad?()
|
onViewDidLoad?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -167,14 +163,14 @@ class MainSidebarViewController: UIViewController {
|
||||||
snapshot.appendItems([
|
snapshot.appendItems([
|
||||||
.tab(.compose)
|
.tab(.compose)
|
||||||
], toSection: .compose)
|
], toSection: .compose)
|
||||||
if mastodonController.instanceFeatures.trends,
|
if mastodonController.instanceFeatures.instanceType.isMastodon,
|
||||||
!Preferences.shared.hideDiscover {
|
!Preferences.shared.hideDiscover {
|
||||||
snapshot.insertSections([.discover], afterSection: .compose)
|
snapshot.insertSections([.discover], afterSection: .compose)
|
||||||
}
|
}
|
||||||
dataSource.apply(snapshot, animatingDifferences: false)
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
|
||||||
applyDiscoverSectionSnapshot()
|
applyDiscoverSectionSnapshot()
|
||||||
reloadLists(mastodonController.lists)
|
reloadLists()
|
||||||
reloadSavedHashtags()
|
reloadSavedHashtags()
|
||||||
reloadSavedInstances()
|
reloadSavedInstances()
|
||||||
}
|
}
|
||||||
|
@ -192,7 +188,7 @@ class MainSidebarViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func ownInstanceLoaded(_ instance: Instance) {
|
private func ownInstanceLoaded(_ instance: Instance) {
|
||||||
if mastodonController.instanceFeatures.trends {
|
if mastodonController.instanceFeatures.instanceType.isMastodon {
|
||||||
var snapshot = self.dataSource.snapshot()
|
var snapshot = self.dataSource.snapshot()
|
||||||
if !snapshot.sectionIdentifiers.contains(.discover) {
|
if !snapshot.sectionIdentifiers.contains(.discover) {
|
||||||
snapshot.appendSections([.discover])
|
snapshot.appendSections([.discover])
|
||||||
|
@ -207,28 +203,42 @@ class MainSidebarViewController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reloadLists(_ lists: [List]) {
|
@objc private func reloadLists() {
|
||||||
var exploreSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
|
let request = Client.getLists()
|
||||||
exploreSnapshot.append([.listsHeader])
|
mastodonController.run(request) { [weak self] (response) in
|
||||||
exploreSnapshot.expand([.listsHeader])
|
guard let self = self, case let .success(lists, _) = response else { return }
|
||||||
exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader)
|
|
||||||
exploreSnapshot.append([.addList], to: .listsHeader)
|
var exploreSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
|
||||||
var selectedItem: Item?
|
exploreSnapshot.append([.listsHeader])
|
||||||
if let selectedIndexPath = collectionView.indexPathsForSelectedItems?.first,
|
exploreSnapshot.expand([.listsHeader])
|
||||||
let item = dataSource.itemIdentifier(for: selectedIndexPath) {
|
exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader)
|
||||||
if case .list(let list) = item,
|
exploreSnapshot.append([.addList], to: .listsHeader)
|
||||||
let newList = lists.first(where: { $0.id == list.id }) {
|
DispatchQueue.main.async {
|
||||||
selectedItem = .list(newList)
|
let selected = self.collectionView.indexPathsForSelectedItems?.first
|
||||||
} else {
|
|
||||||
selectedItem = item
|
self.dataSource.apply(exploreSnapshot, to: .lists) {
|
||||||
|
if let selected = selected {
|
||||||
|
self.collectionView.selectItem(at: selected, animated: false, scrollPosition: .centeredVertically)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.dataSource.apply(exploreSnapshot, to: .lists) {
|
@objc private func listRenamed(_ notification: Foundation.Notification) {
|
||||||
if let selectedItem,
|
let list = notification.userInfo!["list"] as! List
|
||||||
let indexPath = self.dataSource.indexPath(for: selectedItem) {
|
var snapshot = dataSource.snapshot()
|
||||||
self.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredVertically)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -83,14 +83,6 @@ class MainSplitViewController: UISplitViewController {
|
||||||
secondaryNavController.viewControllers = getOrCreateNavigationStack(item: item)
|
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] {
|
func getOrCreateNavigationStack(item: MainSidebarViewController.Item) -> [UIViewController] {
|
||||||
if let existing = navigationStacks[item], existing.count > 0 {
|
if let existing = navigationStacks[item], existing.count > 0 {
|
||||||
return existing
|
return existing
|
||||||
|
@ -386,36 +378,6 @@ extension MainSplitViewController: TuskerNavigationDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MainSplitViewController: TuskerRootViewController {
|
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() {
|
@objc func presentCompose() {
|
||||||
self.compose()
|
self.compose()
|
||||||
}
|
}
|
||||||
|
|
|
@ -233,30 +233,6 @@ extension MainTabBarViewController: TuskerNavigationDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MainTabBarViewController: TuskerRootViewController {
|
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() {
|
@objc func presentCompose() {
|
||||||
compose()
|
compose()
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,6 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
protocol TuskerRootViewController: UIViewController, StatusBarTappableViewController {
|
protocol TuskerRootViewController: UIViewController, StatusBarTappableViewController {
|
||||||
func stateRestorationActivity() -> NSUserActivity?
|
|
||||||
func restoreActivity(_ activity: NSUserActivity)
|
|
||||||
func presentCompose()
|
func presentCompose()
|
||||||
func select(tab: MainTabBarViewController.Tab)
|
func select(tab: MainTabBarViewController.Tab)
|
||||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?
|
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?
|
||||||
|
|
|
@ -23,7 +23,7 @@ struct AdvancedPrefsView : View {
|
||||||
}
|
}
|
||||||
|
|
||||||
var formattingFooter: some View {
|
var formattingFooter: some View {
|
||||||
var s: AttributedString = "This option is only supported with Pleroma and some compatible Mastodon instances (such as Glitch).\n"
|
var s: AttributedString = "This option is only supported with Pleroma and some compatible Mastodon instances (such as Glitch or Hometown).\n"
|
||||||
if let account = LocalData.shared.getMostRecentAccount() {
|
if let account = LocalData.shared.getMostRecentAccount() {
|
||||||
let mastodonController = MastodonController.getForAccount(account)
|
let mastodonController = MastodonController.getForAccount(account)
|
||||||
// shouldn't need to load the instance here, because loading it is kicked off my the scene delegate
|
// shouldn't need to load the instance here, because loading it is kicked off my the scene delegate
|
||||||
|
|
|
@ -17,7 +17,6 @@ struct ComposingPrefsView: View {
|
||||||
visibilitySection
|
visibilitySection
|
||||||
composingSection
|
composingSection
|
||||||
replyingSection
|
replyingSection
|
||||||
writingSection
|
|
||||||
}
|
}
|
||||||
.listStyle(InsetGroupedListStyle())
|
.listStyle(InsetGroupedListStyle())
|
||||||
.navigationBarTitle("Composing")
|
.navigationBarTitle("Composing")
|
||||||
|
@ -77,14 +76,6 @@ struct ComposingPrefsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var writingSection: some View {
|
|
||||||
Section {
|
|
||||||
Toggle(isOn: $preferences.useTwitterKeyboard) {
|
|
||||||
Text("Show @ and # on Keyboard")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ComposingPrefsView_Previews: PreviewProvider {
|
struct ComposingPrefsView_Previews: PreviewProvider {
|
||||||
|
|
|
@ -61,13 +61,6 @@ 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 {
|
enum State {
|
||||||
case unloaded
|
case unloaded
|
||||||
case placeholder(heightConstraint: NSLayoutConstraint)
|
case placeholder(heightConstraint: NSLayoutConstraint)
|
||||||
|
|
|
@ -25,8 +25,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
private var older: RequestRange?
|
private var older: RequestRange?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
var collectionView: UICollectionView! {
|
var collectionView: UICollectionView {
|
||||||
view as? UICollectionView
|
view as! UICollectionView
|
||||||
}
|
}
|
||||||
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
private(set) var headerCell: ProfileHeaderCollectionViewCell?
|
private(set) var headerCell: ProfileHeaderCollectionViewCell?
|
||||||
|
@ -157,7 +157,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
if case .notLoadedInitial = controller.state {
|
if case .notLoadedInitial = await controller.state {
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,163 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,266 +0,0 @@
|
||||||
//
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,165 +0,0 @@
|
||||||
//
|
|
||||||
// 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,15 +16,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
|
|
||||||
private(set) var controller: TimelineLikeController<TimelineItem>!
|
private(set) var controller: TimelineLikeController<TimelineItem>!
|
||||||
let confirmLoadMore = PassthroughSubject<Void, Never>()
|
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
|
// stored separately because i don't want to query the snapshot every time the user scrolls
|
||||||
private var isShowingTimelineDescription = false
|
private var isShowingTimelineDescription = false
|
||||||
|
|
||||||
private(set) var collectionView: UICollectionView!
|
var collectionView: UICollectionView {
|
||||||
|
view as! UICollectionView
|
||||||
|
}
|
||||||
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
|
||||||
private var contentOffsetObservation: NSKeyValueObservation?
|
|
||||||
private var activityToRestore: NSUserActivity?
|
|
||||||
|
|
||||||
init(for timeline: Timeline, mastodonController: MastodonController!) {
|
init(for timeline: Timeline, mastodonController: MastodonController!) {
|
||||||
self.timeline = timeline
|
self.timeline = timeline
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
@ -41,9 +42,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func loadView() {
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
var config = UICollectionLayoutListConfiguration(appearance: .plain)
|
var config = UICollectionLayoutListConfiguration(appearance: .plain)
|
||||||
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
|
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
|
||||||
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
|
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
|
||||||
|
@ -59,24 +58,17 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
if item.hideSeparators {
|
if item.hideSeparators {
|
||||||
config.topSeparatorVisibility = .hidden
|
config.topSeparatorVisibility = .hidden
|
||||||
config.bottomSeparatorVisibility = .hidden
|
config.bottomSeparatorVisibility = .hidden
|
||||||
} else {
|
}
|
||||||
|
if case .status(_, _) = item {
|
||||||
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
let layout = UICollectionViewCompositionalLayout.list(using: config)
|
let layout = UICollectionViewCompositionalLayout.list(using: config)
|
||||||
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
collectionView.delegate = self
|
collectionView.delegate = self
|
||||||
collectionView.dragDelegate = 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()
|
registerTimelineLikeCells()
|
||||||
collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription")
|
collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription")
|
||||||
|
@ -87,15 +79,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
collectionView.refreshControl = UIRefreshControl()
|
collectionView.refreshControl = UIRefreshControl()
|
||||||
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
|
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
|
||||||
#endif
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
contentOffsetObservation = collectionView.observe(\.contentOffset) { [weak self] _, _ in
|
override func viewDidLoad() {
|
||||||
if let indexPath = self?.dataSource.indexPath(for: .gap),
|
super.viewDidLoad()
|
||||||
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
|
// separate method because InstanceTimelineViewController needs to be able to customize it
|
||||||
|
@ -108,9 +95,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
|
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
|
||||||
self.configureStatusCell(cell, id: item.0, state: item.1)
|
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
|
let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
|
||||||
guard case .public(let local) = timeline else {
|
guard case .public(let local) = timeline else {
|
||||||
fatalError()
|
fatalError()
|
||||||
|
@ -125,8 +109,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
switch itemIdentifier {
|
switch itemIdentifier {
|
||||||
case .status(id: let id, state: let state):
|
case .status(id: let id, state: let state):
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
|
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
|
||||||
case .gap:
|
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ())
|
|
||||||
case .loadingIndicator:
|
case .loadingIndicator:
|
||||||
return loadingIndicatorCell(for: indexPath)
|
return loadingIndicatorCell(for: indexPath)
|
||||||
case .confirmLoadMore:
|
case .confirmLoadMore:
|
||||||
|
@ -157,15 +139,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
collectionView.deselectItem(at: $0, animated: true)
|
collectionView.deselectItem(at: $0, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if case .notLoadedInitial = controller.state {
|
Task {
|
||||||
if doRestore() {
|
if case .notLoadedInitial = await controller.state {
|
||||||
Task {
|
await controller.loadInitial()
|
||||||
await checkPresent()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Task {
|
|
||||||
await controller.loadInitial()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -183,106 +159,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stateRestorationActivity() -> NSUserActivity? {
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
let visible = collectionView.indexPathsForVisibleItems.sorted()
|
super.viewDidDisappear(animated)
|
||||||
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)
|
|
||||||
|
|
||||||
let startIndex = max(0, firstVisible.row - 20)
|
// pruneOffscreenRows()
|
||||||
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() {
|
private func removeTimelineDescriptionCell() {
|
||||||
|
@ -292,27 +172,35 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
isShowingTimelineDescription = false
|
isShowingTimelineDescription = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func sceneWillEnterForeground(_ notification: Foundation.Notification) {
|
// private func pruneOffscreenRows() {
|
||||||
guard let scene = notification.object as? UIScene,
|
// guard let lastVisibleIndexPath = collectionView.indexPathsForVisibleItems.last else {
|
||||||
// view.window is nil when the VC is not on screen
|
// return
|
||||||
view.window?.windowScene == scene else {
|
// }
|
||||||
return
|
// var snapshot = dataSource.snapshot()
|
||||||
}
|
// guard snapshot.indexOfSection(.statuses) != nil else {
|
||||||
Task {
|
// return
|
||||||
await checkPresent()
|
// }
|
||||||
}
|
// 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 func refresh() {
|
@objc func refresh() {
|
||||||
Task {
|
Task {
|
||||||
if case .notLoadedInitial = controller.state {
|
if case .notLoadedInitial = await controller.state {
|
||||||
await controller.loadInitial()
|
await controller.loadInitial()
|
||||||
} else {
|
} else {
|
||||||
// I'm not sure whether this should move into TimelineLikeController/TimelineLikeCollectionViewController
|
await controller.loadNewer()
|
||||||
let (_, presentItems) = await (controller.loadNewer(), try? loadInitial())
|
|
||||||
if let presentItems {
|
|
||||||
insertPresentItemsIfNecessary(presentItems)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#if !targetEnvironment(macCatalyst)
|
#if !targetEnvironment(macCatalyst)
|
||||||
collectionView.refreshControl?.endRefreshing()
|
collectionView.refreshControl?.endRefreshing()
|
||||||
|
@ -320,90 +208,6 @@ 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 {
|
extension TimelineViewController {
|
||||||
|
@ -418,7 +222,6 @@ extension TimelineViewController {
|
||||||
typealias TimelineItem = String // status ID
|
typealias TimelineItem = String // status ID
|
||||||
|
|
||||||
case status(id: String, state: StatusState)
|
case status(id: String, state: StatusState)
|
||||||
case gap
|
|
||||||
case loadingIndicator
|
case loadingIndicator
|
||||||
case confirmLoadMore
|
case confirmLoadMore
|
||||||
case publicTimelineDescription
|
case publicTimelineDescription
|
||||||
|
@ -431,8 +234,6 @@ extension TimelineViewController {
|
||||||
switch (lhs, rhs) {
|
switch (lhs, rhs) {
|
||||||
case let (.status(id: a, state: _), .status(id: b, state: _)):
|
case let (.status(id: a, state: _), .status(id: b, state: _)):
|
||||||
return a == b
|
return a == b
|
||||||
case (.gap, .gap):
|
|
||||||
return true
|
|
||||||
case (.loadingIndicator, .loadingIndicator):
|
case (.loadingIndicator, .loadingIndicator):
|
||||||
return true
|
return true
|
||||||
case (.confirmLoadMore, .confirmLoadMore):
|
case (.confirmLoadMore, .confirmLoadMore):
|
||||||
|
@ -449,14 +250,12 @@ extension TimelineViewController {
|
||||||
case .status(id: let id, state: _):
|
case .status(id: let id, state: _):
|
||||||
hasher.combine(0)
|
hasher.combine(0)
|
||||||
hasher.combine(id)
|
hasher.combine(id)
|
||||||
case .gap:
|
|
||||||
hasher.combine(1)
|
|
||||||
case .loadingIndicator:
|
case .loadingIndicator:
|
||||||
hasher.combine(2)
|
hasher.combine(1)
|
||||||
case .confirmLoadMore:
|
case .confirmLoadMore:
|
||||||
hasher.combine(3)
|
hasher.combine(2)
|
||||||
case .publicTimelineDescription:
|
case .publicTimelineDescription:
|
||||||
hasher.combine(4)
|
hasher.combine(3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -471,7 +270,7 @@ extension TimelineViewController {
|
||||||
|
|
||||||
var isSelectable: Bool {
|
var isSelectable: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .publicTimelineDescription, .gap, .status(id: _, state: _):
|
case .publicTimelineDescription, .status(id: _, state: _):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
@ -487,48 +286,50 @@ extension TimelineViewController {
|
||||||
func loadInitial() async throws -> [TimelineItem] {
|
func loadInitial() async throws -> [TimelineItem] {
|
||||||
try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
|
try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
|
||||||
|
|
||||||
|
guard let mastodonController else {
|
||||||
|
throw Error.noClient
|
||||||
|
}
|
||||||
|
|
||||||
let request = Client.getStatuses(timeline: timeline)
|
let request = Client.getStatuses(timeline: timeline)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let (statuses, _) = try await mastodonController.run(request)
|
||||||
|
|
||||||
await withCheckedContinuation { continuation in
|
if !statuses.isEmpty {
|
||||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
newer = .after(id: statuses.first!.id, count: nil)
|
||||||
continuation.resume()
|
older = .before(id: statuses.last!.id, count: nil)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return statuses.map(\.id)
|
return await withCheckedContinuation { continuation in
|
||||||
|
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
|
continuation.resume(returning: statuses.map(\.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadNewer() async throws -> [TimelineItem] {
|
func loadNewer() async throws -> [TimelineItem] {
|
||||||
let statusesSection = dataSource.snapshot().indexOfSection(.statuses)!
|
guard let newer else {
|
||||||
guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: 0, section: statusesSection)) else {
|
|
||||||
throw Error.noNewer
|
throw Error.noNewer
|
||||||
}
|
}
|
||||||
let newer = RequestRange.after(id: id, count: nil)
|
|
||||||
|
|
||||||
let request = Client.getStatuses(timeline: timeline, range: newer)
|
let request = Client.getStatuses(timeline: timeline, range: newer)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let (statuses, _) = try await mastodonController.run(request)
|
||||||
|
|
||||||
guard !statuses.isEmpty else {
|
guard !statuses.isEmpty else {
|
||||||
throw TimelineViewController.Error.allCaughtUp
|
throw Error.allCaughtUp
|
||||||
}
|
}
|
||||||
|
|
||||||
await withCheckedContinuation { continuation in
|
self.newer = .after(id: statuses.first!.id, count: nil)
|
||||||
|
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
continuation.resume()
|
continuation.resume(returning: statuses.map(\.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return statuses.map(\.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadOlder() async throws -> [TimelineItem] {
|
func loadOlder() async throws -> [TimelineItem] {
|
||||||
let snapshot = dataSource.snapshot()
|
guard let older else {
|
||||||
let statusesSection = snapshot.indexOfSection(.statuses)!
|
throw Error.noOlder
|
||||||
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 request = Client.getStatuses(timeline: timeline, range: older)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let (statuses, _) = try await mastodonController.run(request)
|
||||||
|
@ -537,149 +338,13 @@ extension TimelineViewController {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
await withCheckedContinuation { continuation in
|
self.older = .before(id: statuses.last!.id, count: nil)
|
||||||
|
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
continuation.resume()
|
continuation.resume(returning: statuses.map(\.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
enum Error: TimelineLikeCollectionViewError {
|
||||||
|
@ -687,7 +352,6 @@ extension TimelineViewController {
|
||||||
case noNewer
|
case noNewer
|
||||||
case noOlder
|
case noOlder
|
||||||
case allCaughtUp
|
case allCaughtUp
|
||||||
case noGap
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -720,13 +384,6 @@ extension TimelineViewController: UICollectionViewDelegate {
|
||||||
let status = mastodonController.persistentContainer.status(for: id)!
|
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
|
// 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())
|
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:
|
case .loadingIndicator, .confirmLoadMore:
|
||||||
fatalError("unreachable")
|
fatalError("unreachable")
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,22 +46,4 @@ class TimelinesPageViewController: SegmentedPageViewController {
|
||||||
fatalError("init(coder:) has not been implemented")
|
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,7 +357,6 @@ class CustomAlertActionButton: UIControl {
|
||||||
titleView = UIStackView()
|
titleView = UIStackView()
|
||||||
titleView.axis = .horizontal
|
titleView.axis = .horizontal
|
||||||
titleView.spacing = 4
|
titleView.spacing = 4
|
||||||
titleView.alignment = .center
|
|
||||||
|
|
||||||
if let title = action.title {
|
if let title = action.title {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
|
|
|
@ -66,22 +66,8 @@ extension MenuActionProvider {
|
||||||
]
|
]
|
||||||
var suppressSection: [UIMenuElement] = []
|
var suppressSection: [UIMenuElement] = []
|
||||||
|
|
||||||
if let ownAccount = mastodonController.account,
|
if accountID != loggedInAccountID {
|
||||||
accountID != ownAccount.id {
|
|
||||||
actionsSection.append(relationshipAction(accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.followAction(for: $0, mastodonController: $1) }))
|
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.blockAction(for: $0, mastodonController: $1) }))
|
||||||
suppressSection.append(relationshipAction(accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.muteAction(for: $0, mastodonController: $1) }))
|
suppressSection.append(relationshipAction(accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.muteAction(for: $0, mastodonController: $1) }))
|
||||||
}
|
}
|
||||||
|
@ -138,7 +124,7 @@ extension MenuActionProvider {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
func actionsForStatus(_ status: StatusMO, sourceView: UIView?, includeStatusButtonActions: Bool = true) -> [UIMenuElement] {
|
func actionsForStatus(_ status: StatusMO, sourceView: UIView?, includeReply: Bool = true) -> [UIMenuElement] {
|
||||||
guard let mastodonController = mastodonController else { return [] }
|
guard let mastodonController = mastodonController else { return [] }
|
||||||
|
|
||||||
guard let accountID = mastodonController.accountInfo?.id else {
|
guard let accountID = mastodonController.accountInfo?.id else {
|
||||||
|
@ -169,8 +155,7 @@ extension MenuActionProvider {
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
if #available(iOS 16.0, *),
|
if #available(iOS 16.0, *) {
|
||||||
includeStatusButtonActions {
|
|
||||||
let favorited = status.favourited
|
let favorited = status.favourited
|
||||||
// TODO: move this color into an asset catalog or something
|
// TODO: move this color into an asset catalog or something
|
||||||
var favImage = UIImage(systemName: favorited ? "star.fill" : "star")!
|
var favImage = UIImage(systemName: favorited ? "star.fill" : "star")!
|
||||||
|
@ -201,7 +186,7 @@ extension MenuActionProvider {
|
||||||
|
|
||||||
var actionsSection: [UIAction] = []
|
var actionsSection: [UIAction] = []
|
||||||
|
|
||||||
if includeStatusButtonActions {
|
if includeReply {
|
||||||
actionsSection.insert(createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { [weak self] (_) in
|
actionsSection.insert(createAction(identifier: "reply", title: "Reply", systemImageName: "arrowshape.turn.up.left", handler: { [weak self] (_) in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.navigationDelegate?.compose(inReplyToID: status.id)
|
self.navigationDelegate?.compose(inReplyToID: status.id)
|
||||||
|
@ -279,9 +264,8 @@ extension MenuActionProvider {
|
||||||
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID))
|
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID))
|
||||||
|
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
let toggleableAndActions = toggleableSection + actionsSection
|
|
||||||
return [
|
return [
|
||||||
UIMenu(options: .displayInline, preferredElementSize: toggleableAndActions.count == 1 ? .large : .medium, children: toggleableAndActions),
|
UIMenu(options: .displayInline, preferredElementSize: .medium, children: toggleableSection + actionsSection),
|
||||||
UIMenu(options: .displayInline, children: shareSection),
|
UIMenu(options: .displayInline, children: shareSection),
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -19,7 +19,7 @@ protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeCon
|
||||||
var controller: TimelineLikeController<TimelineItem>! { get }
|
var controller: TimelineLikeController<TimelineItem>! { get }
|
||||||
var confirmLoadMore: PassthroughSubject<Void, Never> { get }
|
var confirmLoadMore: PassthroughSubject<Void, Never> { get }
|
||||||
|
|
||||||
var collectionView: UICollectionView! { get }
|
var collectionView: UICollectionView { get }
|
||||||
var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get }
|
var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +64,7 @@ extension TimelineLikeCollectionViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleAddLoadingIndicator() async {
|
func handleAddLoadingIndicator() async {
|
||||||
if case .loadingInitial(_, _) = controller.state,
|
if case .loadingInitial(_, _) = await controller.state,
|
||||||
let refreshControl = collectionView.refreshControl,
|
let refreshControl = collectionView.refreshControl,
|
||||||
refreshControl.isRefreshing {
|
refreshControl.isRefreshing {
|
||||||
refreshControl.beginRefreshing()
|
refreshControl.beginRefreshing()
|
||||||
|
@ -85,7 +85,7 @@ extension TimelineLikeCollectionViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleRemoveLoadingIndicator() async {
|
func handleRemoveLoadingIndicator() async {
|
||||||
if case .loadingInitial(_, _) = controller.state,
|
if case .loadingInitial(_, _) = await controller.state,
|
||||||
let refreshControl = collectionView.refreshControl,
|
let refreshControl = collectionView.refreshControl,
|
||||||
refreshControl.isRefreshing {
|
refreshControl.isRefreshing {
|
||||||
refreshControl.endRefreshing()
|
refreshControl.endRefreshing()
|
||||||
|
@ -179,17 +179,6 @@ extension TimelineLikeCollectionViewController {
|
||||||
snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries)
|
snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries)
|
||||||
await apply(snapshot, animatingDifferences: false)
|
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 {
|
extension TimelineLikeCollectionViewController {
|
||||||
|
@ -217,7 +206,7 @@ extension TimelineLikeCollectionViewController {
|
||||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell
|
||||||
cell.confirmLoadMore = self.confirmLoadMore
|
cell.confirmLoadMore = self.confirmLoadMore
|
||||||
Task {
|
Task {
|
||||||
if case .loadingOlder(_, _) = controller.state {
|
if case .loadingOlder(_, _) = await controller.state {
|
||||||
cell.isLoading = true
|
cell.isLoading = true
|
||||||
} else {
|
} else {
|
||||||
cell.isLoading = false
|
cell.isLoading = false
|
||||||
|
|
|
@ -22,18 +22,6 @@ extension NSUserActivity {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isStateRestorationActivity: Bool {
|
|
||||||
get {
|
|
||||||
(userInfo?["isStateRestorationActivity"] as? Bool) ?? false
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
if userInfo == nil {
|
|
||||||
userInfo = [:]
|
|
||||||
}
|
|
||||||
userInfo!["isStateRestorationActivity"] = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
convenience init(type: UserActivityType) {
|
convenience init(type: UserActivityType) {
|
||||||
self.init(activityType: type.rawValue)
|
self.init(activityType: type.rawValue)
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,11 +16,9 @@ protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
|
||||||
|
|
||||||
func loadNewer() async throws -> [TimelineItem]
|
func loadNewer() async throws -> [TimelineItem]
|
||||||
|
|
||||||
func canLoadOlder() async -> Bool
|
|
||||||
|
|
||||||
func loadOlder() async throws -> [TimelineItem]
|
func loadOlder() async throws -> [TimelineItem]
|
||||||
|
|
||||||
func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem]
|
func canLoadOlder() async -> Bool
|
||||||
|
|
||||||
func handleAddLoadingIndicator() async
|
func handleAddLoadingIndicator() async
|
||||||
func handleRemoveLoadingIndicator() async
|
func handleRemoveLoadingIndicator() async
|
||||||
|
@ -30,16 +28,13 @@ protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
|
||||||
func handlePrependItems(_ timelineItems: [TimelineItem]) async
|
func handlePrependItems(_ timelineItems: [TimelineItem]) async
|
||||||
func handleLoadOlderError(_ error: Swift.Error) async
|
func handleLoadOlderError(_ error: Swift.Error) async
|
||||||
func handleAppendItems(_ timelineItems: [TimelineItem]) 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")
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController")
|
||||||
|
|
||||||
@MainActor
|
actor TimelineLikeController<Item> {
|
||||||
class TimelineLikeController<Item> {
|
|
||||||
|
|
||||||
private unowned var delegate: any TimelineLikeControllerDelegate<Item>
|
unowned var delegate: any TimelineLikeControllerDelegate<Item>
|
||||||
|
|
||||||
private(set) var state = State.notLoadedInitial {
|
private(set) var state = State.notLoadedInitial {
|
||||||
willSet {
|
willSet {
|
||||||
|
@ -79,16 +74,6 @@ class 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 {
|
func loadNewer() async {
|
||||||
guard state == .idle else {
|
guard state == .idle else {
|
||||||
return
|
return
|
||||||
|
@ -141,27 +126,6 @@ class 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) {
|
private func transition(to newState: State) {
|
||||||
self.state = newState
|
self.state = newState
|
||||||
}
|
}
|
||||||
|
@ -188,21 +152,15 @@ class TimelineLikeController<Item> {
|
||||||
await delegate.handleLoadOlderError(error)
|
await delegate.handleLoadOlderError(error)
|
||||||
case .appendItems(let items, _):
|
case .appendItems(let items, _):
|
||||||
await delegate.handleAppendItems(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 {
|
enum State: Equatable, CustomDebugStringConvertible {
|
||||||
case notLoadedInitial
|
case notLoadedInitial
|
||||||
case idle
|
case idle
|
||||||
case restoringInitial
|
|
||||||
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
||||||
case loadingNewer(LoadAttemptToken)
|
case loadingNewer(LoadAttemptToken)
|
||||||
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
||||||
case loadingGap(LoadAttemptToken, TimelineGapDirection)
|
|
||||||
|
|
||||||
var debugDescription: String {
|
var debugDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
@ -210,16 +168,12 @@ class TimelineLikeController<Item> {
|
||||||
return "notLoadedInitial"
|
return "notLoadedInitial"
|
||||||
case .idle:
|
case .idle:
|
||||||
return "idle"
|
return "idle"
|
||||||
case .restoringInitial:
|
|
||||||
return "restoringInitial"
|
|
||||||
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
||||||
return "loadingInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
return "loadingInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
||||||
case .loadingNewer(let token):
|
case .loadingNewer(let token):
|
||||||
return "loadingNewer(\(ObjectIdentifier(token)))"
|
return "loadingNewer(\(ObjectIdentifier(token)))"
|
||||||
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
||||||
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
||||||
case .loadingGap(let token, let direction):
|
|
||||||
return "loadingGap(\(ObjectIdentifier(token)), \(direction))"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -227,28 +181,24 @@ class TimelineLikeController<Item> {
|
||||||
switch self {
|
switch self {
|
||||||
case .notLoadedInitial:
|
case .notLoadedInitial:
|
||||||
switch to {
|
switch to {
|
||||||
case .restoringInitial, .loadingInitial(_, _):
|
case .loadingInitial(_, _):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
case .idle:
|
case .idle:
|
||||||
switch to {
|
switch to {
|
||||||
case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _):
|
case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
case .restoringInitial:
|
|
||||||
return to == .idle
|
|
||||||
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
||||||
return to == .notLoadedInitial || to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true))
|
return to == .notLoadedInitial || to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true))
|
||||||
case .loadingNewer(_):
|
case .loadingNewer(_):
|
||||||
return to == .idle
|
return to == .idle
|
||||||
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
||||||
return to == .idle || (!hasAddedLoadingIndicator && to == .loadingOlder(token, hasAddedLoadingIndicator: true))
|
return to == .idle || (!hasAddedLoadingIndicator && to == .loadingOlder(token, hasAddedLoadingIndicator: true))
|
||||||
case .loadingGap(_, _):
|
|
||||||
return to == .idle
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -289,13 +239,6 @@ class TimelineLikeController<Item> {
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
case .loadGapError(_, let direction, let token), .fillGap(_, let direction, let token):
|
|
||||||
switch self {
|
|
||||||
case .loadingGap(token, direction):
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -309,8 +252,6 @@ class TimelineLikeController<Item> {
|
||||||
case prependItems([Item], LoadAttemptToken)
|
case prependItems([Item], LoadAttemptToken)
|
||||||
case loadOlderError(Error, LoadAttemptToken)
|
case loadOlderError(Error, LoadAttemptToken)
|
||||||
case appendItems([Item], LoadAttemptToken)
|
case appendItems([Item], LoadAttemptToken)
|
||||||
case loadGapError(Error, TimelineGapDirection, LoadAttemptToken)
|
|
||||||
case fillGap([Item], TimelineGapDirection, LoadAttemptToken)
|
|
||||||
|
|
||||||
var debugDescription: String {
|
var debugDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
@ -330,10 +271,6 @@ class TimelineLikeController<Item> {
|
||||||
return "loadOlderError(\(error), \(token))"
|
return "loadOlderError(\(error), \(token))"
|
||||||
case .appendItems(_, let token):
|
case .appendItems(_, let token):
|
||||||
return "appendItems(<omitted>, \(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))"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -373,10 +310,3 @@ class 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]) {
|
func showFollowedByList(accountIDs: [String]) {
|
||||||
let vc = AccountListViewController(accountIDs: accountIDs, mastodonController: apiController)
|
let vc = AccountListTableViewController(accountIDs: accountIDs, mastodonController: apiController)
|
||||||
vc.title = NSLocalizedString("Followed By", comment: "followed by accounts list title")
|
vc.title = NSLocalizedString("Followed By", comment: "followed by accounts list title")
|
||||||
show(vc, sender: self)
|
show(vc, sender: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusActionAccountList(action: StatusActionAccountListViewController.ActionType, statusID: String, statusState state: StatusState, accountIDs: [String]?) -> StatusActionAccountListViewController {
|
func statusActionAccountList(action: StatusActionAccountListTableViewController.ActionType, statusID: String, statusState state: StatusState, accountIDs: [String]?) -> StatusActionAccountListTableViewController {
|
||||||
return StatusActionAccountListViewController(actionType: action, statusID: statusID, statusState: state, accountIDs: accountIDs, mastodonController: apiController)
|
return StatusActionAccountListTableViewController(actionType: action, statusID: statusID, statusState: state, accountIDs: accountIDs, mastodonController: apiController)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,148 +0,0 @@
|
||||||
//
|
|
||||||
// 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,7 +56,6 @@ class CachedImageView: UIImageView {
|
||||||
guard let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) else {
|
guard let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try Task.checkCancellation()
|
|
||||||
self.image = transformedImage
|
self.image = transformedImage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,7 @@ class ConfirmLoadMoreCollectionViewCell: UICollectionViewCell {
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
|
|
||||||
backgroundColor = .systemGroupedBackground
|
backgroundColor = .secondarySystemBackground
|
||||||
|
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.text = "Infinite scrolling is off. Do you want to keep going?"
|
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 }
|
guard let delegate = delegate else { return }
|
||||||
let notifications = group.notifications
|
let notifications = group.notifications
|
||||||
let accountIDs = notifications.map { $0.account.id }
|
let accountIDs = notifications.map { $0.account.id }
|
||||||
let action: StatusActionAccountListViewController.ActionType
|
let action: StatusActionAccountListTableViewController.ActionType
|
||||||
switch notifications.first!.kind {
|
switch notifications.first!.kind {
|
||||||
case .favourite:
|
case .favourite:
|
||||||
action = .favorite
|
action = .favorite
|
||||||
|
@ -228,7 +228,6 @@ extension ActionNotificationGroupTableViewCell: SelectableTableViewCell {
|
||||||
fatalError()
|
fatalError()
|
||||||
}
|
}
|
||||||
let vc = delegate.statusActionAccountList(action: action, statusID: statusID, statusState: .unknown, accountIDs: accountIDs)
|
let vc = delegate.statusActionAccountList(action: action, statusID: statusID, statusState: .unknown, accountIDs: accountIDs)
|
||||||
vc.showInacurateCountWarning = false
|
|
||||||
delegate.show(vc)
|
delegate.show(vc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -236,12 +235,9 @@ extension ActionNotificationGroupTableViewCell: SelectableTableViewCell {
|
||||||
extension ActionNotificationGroupTableViewCell: MenuPreviewProvider {
|
extension ActionNotificationGroupTableViewCell: MenuPreviewProvider {
|
||||||
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
|
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
|
||||||
return (content: {
|
return (content: {
|
||||||
guard let delegate = self.delegate else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let notifications = self.group.notifications
|
let notifications = self.group.notifications
|
||||||
let accountIDs = notifications.map { $0.account.id }
|
let accountIDs = notifications.map { $0.account.id }
|
||||||
let action: StatusActionAccountListViewController.ActionType
|
let action: StatusActionAccountListTableViewController.ActionType
|
||||||
switch notifications.first!.kind {
|
switch notifications.first!.kind {
|
||||||
case .favourite:
|
case .favourite:
|
||||||
action = .favorite
|
action = .favorite
|
||||||
|
@ -250,9 +246,7 @@ extension ActionNotificationGroupTableViewCell: MenuPreviewProvider {
|
||||||
default:
|
default:
|
||||||
fatalError()
|
fatalError()
|
||||||
}
|
}
|
||||||
let vc = delegate.statusActionAccountList(action: action, statusID: self.statusID, statusState: .unknown, accountIDs: accountIDs)
|
return self.delegate?.statusActionAccountList(action: action, statusID: self.statusID, statusState: .unknown, accountIDs: accountIDs)
|
||||||
vc.showInacurateCountWarning = false
|
|
||||||
return vc
|
|
||||||
}, actions: {
|
}, actions: {
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
|
|
@ -210,7 +210,7 @@ extension FollowNotificationGroupTableViewCell: MenuPreviewProvider {
|
||||||
if accountIDs.count == 1 {
|
if accountIDs.count == 1 {
|
||||||
return ProfileViewController(accountID: accountIDs.first!, mastodonController: mastodonController)
|
return ProfileViewController(accountID: accountIDs.first!, mastodonController: mastodonController)
|
||||||
} else {
|
} else {
|
||||||
return AccountListViewController(accountIDs: accountIDs, mastodonController: mastodonController)
|
return AccountListTableViewController(accountIDs: accountIDs, mastodonController: mastodonController)
|
||||||
}
|
}
|
||||||
}, actions: {
|
}, actions: {
|
||||||
if accountIDs.count == 1 {
|
if accountIDs.count == 1 {
|
||||||
|
|
|
@ -95,7 +95,7 @@ class StatusPollView: UIView {
|
||||||
guard let poll = poll else { return }
|
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
|
// 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
|
canVote = false
|
||||||
} else {
|
} else {
|
||||||
canVote = true
|
canVote = true
|
||||||
|
@ -126,7 +126,7 @@ class StatusPollView: UIView {
|
||||||
if expired {
|
if expired {
|
||||||
voteButton.disabledTitle = "Expired"
|
voteButton.disabledTitle = "Expired"
|
||||||
} else if poll.voted ?? false {
|
} else if poll.voted ?? false {
|
||||||
if status.account.id == mastodonController.account?.id {
|
if status.account.id == mastodonController.account.id {
|
||||||
voteButton.isHidden = true
|
voteButton.isHidden = true
|
||||||
} else {
|
} else {
|
||||||
voteButton.disabledTitle = "Voted"
|
voteButton.disabledTitle = "Voted"
|
||||||
|
|
|
@ -7,15 +7,13 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
class ProfileFieldsView: UIView {
|
class ProfileFieldsView: UIView {
|
||||||
|
|
||||||
weak var delegate: ProfileHeaderViewDelegate?
|
weak var delegate: ProfileHeaderViewDelegate?
|
||||||
|
|
||||||
private let stack = UIStackView()
|
private let stack = UIStackView()
|
||||||
private var fieldViews: [(EmojiLabel, ProfileFieldValueView)] = []
|
private var fieldViews: [(EmojiLabel, ContentTextView)] = []
|
||||||
private var fieldConstraints: [NSLayoutConstraint] = []
|
private var fieldConstraints: [NSLayoutConstraint] = []
|
||||||
|
|
||||||
private var isUsingSingleColumn: Bool = false
|
private var isUsingSingleColumn: Bool = false
|
||||||
|
@ -82,11 +80,16 @@ class ProfileFieldsView: UIView {
|
||||||
nameLabel.setEmojis(account.emojis, identifier: account.id)
|
nameLabel.setEmojis(account.emojis, identifier: account.id)
|
||||||
nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
|
||||||
let valueView = ProfileFieldValueView(field: field, account: account)
|
let valueTextView = ContentTextView()
|
||||||
valueView.navigationDelegate = delegate
|
valueTextView.isSelectable = false
|
||||||
valueView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
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)
|
||||||
|
|
||||||
fieldViews.append((nameLabel, valueView))
|
fieldViews.append((nameLabel, valueTextView))
|
||||||
}
|
}
|
||||||
|
|
||||||
configureFields()
|
configureFields()
|
||||||
|
@ -118,7 +121,7 @@ class ProfileFieldsView: UIView {
|
||||||
}
|
}
|
||||||
name.textAlignment = .natural
|
name.textAlignment = .natural
|
||||||
stack.addArrangedSubview(name)
|
stack.addArrangedSubview(name)
|
||||||
value.setTextAlignment(.natural)
|
value.textAlignment = .natural
|
||||||
stack.addArrangedSubview(value)
|
stack.addArrangedSubview(value)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -134,7 +137,7 @@ class ProfileFieldsView: UIView {
|
||||||
name.textAlignment = .right
|
name.textAlignment = .right
|
||||||
name.translatesAutoresizingMaskIntoConstraints = false
|
name.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
value.setTextAlignment(.left)
|
value.textAlignment = .left
|
||||||
value.translatesAutoresizingMaskIntoConstraints = false
|
value.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
let fieldContainer = UIView()
|
let fieldContainer = UIView()
|
||||||
|
@ -162,159 +165,3 @@ 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
|
let reblogDisabled: Bool
|
||||||
if mastodonController.instanceFeatures.boostToOriginalAudience {
|
if mastodonController.instanceFeatures.boostToOriginalAudience {
|
||||||
// Pleroma allows 'Boost to original audience' for your own private posts
|
// 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 {
|
} else {
|
||||||
reblogDisabled = status.visibility == .private || status.visibility == .direct
|
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
|
// 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
|
// 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, includeStatusButtonActions: false) ?? [])
|
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, sourceView: moreButton, includeReply: false) ?? [])
|
||||||
|
|
||||||
pollView.isHidden = status.poll == nil
|
pollView.isHidden = status.poll == nil
|
||||||
pollView.mastodonController = mastodonController
|
pollView.mastodonController = mastodonController
|
||||||
|
|
|
@ -131,7 +131,7 @@ extension StatusCollectionViewCell {
|
||||||
}
|
}
|
||||||
if status.visibility == .direct || status.visibility == .private {
|
if status.visibility == .direct || status.visibility == .private {
|
||||||
if mastodonController.instanceFeatures.boostToOriginalAudience,
|
if mastodonController.instanceFeatures.boostToOriginalAudience,
|
||||||
status.account.id == mastodonController.account?.id {
|
status.account.id == mastodonController.account.id {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
@ -195,7 +195,7 @@ extension StatusCollectionViewCell {
|
||||||
|
|
||||||
// keep menu in sync with changed states e.g. bookmarked, muted
|
// 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
|
// 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, includeStatusButtonActions: false) ?? [])
|
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, sourceView: moreButton, includeReply: false) ?? [])
|
||||||
|
|
||||||
contentContainer.pollView.isHidden = status.poll == nil
|
contentContainer.pollView.isHidden = status.poll == nil
|
||||||
contentContainer.pollView.mastodonController = mastodonController
|
contentContainer.pollView.mastodonController = mastodonController
|
||||||
|
|
|
@ -16,20 +16,10 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
|
|
||||||
// MARK: Subviews
|
// MARK: Subviews
|
||||||
|
|
||||||
private lazy var rebloggerLabel = EmojiLabel().configure {
|
private lazy var reblogLabel = EmojiLabel().configure {
|
||||||
$0.textColor = .secondaryLabel
|
$0.textColor = .secondaryLabel
|
||||||
$0.font = .preferredFont(forTextStyle: .body)
|
$0.font = .preferredFont(forTextStyle: .body)
|
||||||
$0.adjustsFontForContentSizeCategory = true
|
$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
|
// this needs to have a higher priorty than the content container's zero height constraint
|
||||||
$0.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
$0.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||||
$0.isUserInteractionEnabled = true
|
$0.isUserInteractionEnabled = true
|
||||||
|
@ -275,12 +265,12 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
|
|
||||||
for subview in [reblogHStack, mainContainer, actionsContainer] {
|
for subview in [reblogLabel, mainContainer, actionsContainer] {
|
||||||
subview.translatesAutoresizingMaskIntoConstraints = false
|
subview.translatesAutoresizingMaskIntoConstraints = false
|
||||||
contentView.addSubview(subview)
|
contentView.addSubview(subview)
|
||||||
}
|
}
|
||||||
|
|
||||||
mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: reblogHStack.bottomAnchor, constant: 4)
|
mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: reblogLabel.bottomAnchor, constant: 4)
|
||||||
mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8)
|
mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8)
|
||||||
mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor, constant: -4)
|
mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor, constant: -4)
|
||||||
mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6)
|
mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6)
|
||||||
|
@ -291,9 +281,9 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
// why is this 4 but the mainContainerTopSelfConstraint constant 8? because this looks more balanced
|
// why is this 4 but the mainContainerTopSelfConstraint constant 8? because this looks more balanced
|
||||||
reblogHStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4),
|
reblogLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4),
|
||||||
rebloggerLabel.leadingAnchor.constraint(equalTo: contentVStack.leadingAnchor),
|
reblogLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||||
reblogHStack.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -16),
|
reblogLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
||||||
|
|
||||||
mainContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
mainContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||||
mainContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
mainContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
||||||
|
@ -409,7 +399,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
if let rebloggedStatus = status.reblog {
|
if let rebloggedStatus = status.reblog {
|
||||||
reblogStatusID = statusID
|
reblogStatusID = statusID
|
||||||
rebloggerID = status.account.id
|
rebloggerID = status.account.id
|
||||||
reblogHStack.isHidden = false
|
reblogLabel.isHidden = false
|
||||||
mainContainerTopToReblogLabelConstraint.isActive = true
|
mainContainerTopToReblogLabelConstraint.isActive = true
|
||||||
mainContainerTopToSelfConstraint.isActive = false
|
mainContainerTopToSelfConstraint.isActive = false
|
||||||
updateRebloggerLabel(reblogger: status.account)
|
updateRebloggerLabel(reblogger: status.account)
|
||||||
|
@ -418,7 +408,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
} else {
|
} else {
|
||||||
reblogStatusID = nil
|
reblogStatusID = nil
|
||||||
rebloggerID = nil
|
rebloggerID = nil
|
||||||
reblogHStack.isHidden = true
|
reblogLabel.isHidden = true
|
||||||
mainContainerTopToReblogLabelConstraint.isActive = false
|
mainContainerTopToReblogLabelConstraint.isActive = false
|
||||||
mainContainerTopToSelfConstraint.isActive = true
|
mainContainerTopToSelfConstraint.isActive = true
|
||||||
}
|
}
|
||||||
|
@ -502,11 +492,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
||||||
|
|
||||||
private func updateRebloggerLabel(reblogger: AccountMO) {
|
private func updateRebloggerLabel(reblogger: AccountMO) {
|
||||||
if Preferences.shared.hideCustomEmojiInUsernames {
|
if Preferences.shared.hideCustomEmojiInUsernames {
|
||||||
rebloggerLabel.text = "\(reblogger.displayNameWithoutCustomEmoji) reblogged"
|
reblogLabel.text = "Reblogged by \(reblogger.displayNameWithoutCustomEmoji)"
|
||||||
rebloggerLabel.removeEmojis()
|
reblogLabel.removeEmojis()
|
||||||
} else {
|
} else {
|
||||||
rebloggerLabel.text = "\(reblogger.displayOrUserName) reblogged"
|
reblogLabel.text = "Reblogged by \(reblogger.displayOrUserName)"
|
||||||
rebloggerLabel.setEmojis(reblogger.emojis, identifier: reblogger.id)
|
reblogLabel.setEmojis(reblogger.emojis, identifier: reblogger.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ import Pachyderm
|
||||||
|
|
||||||
struct ToastConfiguration {
|
struct ToastConfiguration {
|
||||||
var systemImageName: String?
|
var systemImageName: String?
|
||||||
var titleFont: UIFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .boldSystemFont(ofSize: 14))
|
var titleFont: UIFont = .boldSystemFont(ofSize: 14)
|
||||||
var title: String
|
var title: String
|
||||||
var subtitle: String?
|
var subtitle: String?
|
||||||
var actionTitle: String?
|
var actionTitle: String?
|
||||||
|
|
|
@ -68,15 +68,13 @@ class ToastView: UIView {
|
||||||
titleLabel.textColor = .white
|
titleLabel.textColor = .white
|
||||||
titleLabel.font = configuration.titleFont
|
titleLabel.font = configuration.titleFont
|
||||||
titleLabel.adjustsFontSizeToFitWidth = true
|
titleLabel.adjustsFontSizeToFitWidth = true
|
||||||
titleLabel.adjustsFontForContentSizeCategory = true
|
|
||||||
|
|
||||||
if let subtitle = configuration.subtitle {
|
if let subtitle = configuration.subtitle {
|
||||||
let subtitleLabel = UILabel()
|
let subtitleLabel = UILabel()
|
||||||
subtitleLabel.text = subtitle
|
subtitleLabel.text = subtitle
|
||||||
subtitleLabel.textColor = .white
|
subtitleLabel.textColor = .white
|
||||||
subtitleLabel.numberOfLines = 0
|
subtitleLabel.numberOfLines = 0
|
||||||
subtitleLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14))
|
subtitleLabel.font = .systemFont(ofSize: 14)
|
||||||
subtitleLabel.adjustsFontForContentSizeCategory = true
|
|
||||||
let vStack = UIStackView(arrangedSubviews: [
|
let vStack = UIStackView(arrangedSubviews: [
|
||||||
titleLabel,
|
titleLabel,
|
||||||
subtitleLabel
|
subtitleLabel
|
||||||
|
@ -91,8 +89,7 @@ class ToastView: UIView {
|
||||||
if let actionTitle = configuration.actionTitle {
|
if let actionTitle = configuration.actionTitle {
|
||||||
let actionLabel = UILabel()
|
let actionLabel = UILabel()
|
||||||
actionLabel.text = actionTitle
|
actionLabel.text = actionTitle
|
||||||
actionLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .boldSystemFont(ofSize: 16))
|
actionLabel.font = .boldSystemFont(ofSize: 16)
|
||||||
actionLabel.adjustsFontForContentSizeCategory = true
|
|
||||||
actionLabel.textColor = .white
|
actionLabel.textColor = .white
|
||||||
stack.addArrangedSubview(actionLabel)
|
stack.addArrangedSubview(actionLabel)
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,7 @@ class VisualEffectImageButton: UIControl {
|
||||||
override func awakeFromNib() {
|
override func awakeFromNib() {
|
||||||
super.awakeFromNib()
|
super.awakeFromNib()
|
||||||
|
|
||||||
let blur = UIBlurEffect(style: .systemThickMaterial)
|
let blur = UIBlurEffect(style: .prominent)
|
||||||
let blurView = UIVisualEffectView(effect: blur)
|
let blurView = UIVisualEffectView(effect: blur)
|
||||||
blurView.translatesAutoresizingMaskIntoConstraints = false
|
blurView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
let vibrancy = UIVibrancyEffect(blurEffect: blur, style: .label)
|
let vibrancy = UIVibrancyEffect(blurEffect: blur, style: .label)
|
||||||
|
|
Loading…
Reference in New Issue