Compare commits

...

36 Commits

Author SHA1 Message Date
Shadowfacts 76fc73de95 Bump build number and update changelog 2022-11-23 12:25:27 -05:00
Shadowfacts 40800f964d Fix jump to present not scrolling all the way to the top 2022-11-23 11:58:52 -05:00
Shadowfacts 9f7d16a70e Don't show duplicate actions in status cell more actions menu 2022-11-23 11:47:00 -05:00
Shadowfacts c2cb0a0c5a Timeline state restoration 2022-11-23 11:35:25 -05:00
Shadowfacts 272f35417b Rewrite account list VC using UICollectionView 2022-11-22 15:38:40 -05:00
Shadowfacts 848c3dd950 Rewrite status action account list to use UICollectionView 2022-11-22 15:29:17 -05:00
Shadowfacts dfeb39b31f Fix selecting draft not working
Closes #263
2022-11-22 14:00:41 -05:00
Shadowfacts bab5226f2a Fix albums in asset picker not being sorted by name 2022-11-22 13:57:56 -05:00
Shadowfacts 88cfbfb1f3 Improve reblog indicator on statuses
Closes #225
2022-11-22 11:48:59 -05:00
Shadowfacts 49f1d6339f Fix crash when toggling collapse in Trending Posts
Closes #262
2022-11-22 11:47:57 -05:00
Shadowfacts 3e7cb443fa Correct post content type warning
Hometown does not support formatting
2022-11-22 11:39:47 -05:00
Shadowfacts b5c8a38b9b Add preference for using twitter-style keyboard 2022-11-22 11:06:21 -05:00
Shadowfacts ab19922530 Indicate verified profile links
Closes #241
2022-11-22 11:00:52 -05:00
Shadowfacts 45c844b065 Separate Shared Albums section in asset picker
Closes #244
2022-11-21 23:21:21 -05:00
Shadowfacts 47b838a386 Change timeline gap-filling to do a proper job of maintaining the bottom-relative scroll position 2022-11-21 22:47:44 -05:00
Shadowfacts 276691efbf Embiggen gallery share/close buttons
Closes #257
2022-11-20 21:37:57 -05:00
Shadowfacts 0a8d50cc27 Fix double-tap to zoom in gallery not working
Closes #256
2022-11-20 15:48:29 -05:00
Shadowfacts 11e81acbc1 Fix toasts not adjusting font for Dynamic Type 2022-11-20 14:15:21 -05:00
Shadowfacts fb2c9b341c Fix custom alert action icon getting squished when Dynamic Type is on
Closes #254
2022-11-20 14:12:00 -05:00
Shadowfacts 810ae71832 Make poll options in Compose reorderable with drag/drop 2022-11-20 14:06:45 -05:00
Shadowfacts 001a73af3c Workaround for profile header changing size when statuses are loaded in the background
Closes #250
2022-11-20 13:57:51 -05:00
Shadowfacts c8375b742a Make more actions button on profiles more prominent 2022-11-19 14:29:21 -05:00
Shadowfacts 9feef054fc Fix list timeline VC presenting edit screen repeatedly 2022-11-19 14:22:26 -05:00
Shadowfacts bf87ae7a7d Add Add to List menu action to accounts
Closes #247
2022-11-19 14:22:26 -05:00
Shadowfacts f8de6f9e10 Fix follow/block/mute actions showing up on user's own account 2022-11-19 14:10:19 -05:00
Shadowfacts ab47fa776e Store lists on MastodonController 2022-11-19 14:08:39 -05:00
Shadowfacts 7178473f34 Fix compose toolbar being hidden by software keyboard on iPadOS 15
Closes #252
2022-11-19 13:35:34 -05:00
Shadowfacts c8319d8af2 Remove old and debug code 2022-11-19 13:11:29 -05:00
Shadowfacts 9ff1452c68 Show jump to present toast if necessary when scene re-appears 2022-11-19 13:09:37 -05:00
Shadowfacts ce534c4a05 Actual gap cell implementation 2022-11-19 11:15:14 -05:00
Shadowfacts 0fddf94292 Timeline jump to present 2022-11-18 20:49:15 -05:00
Shadowfacts 8276e99d27 Timeline gaps and gap filling 2022-11-18 17:29:55 -05:00
Shadowfacts a5ad8e43b1 Disable attachment colorspace conversion on Mastodon v4 2022-11-15 21:45:42 -05:00
Shadowfacts ce7ce3ac92 Fix crash when requests race with own account
If the notifications/etc load first, and the table view cells are
created, mastodonController.account may still be nil
2022-11-14 21:38:24 -05:00
Shadowfacts 99a1c76cb1 Clean up instance type/feature detection
Add akkoma detection
2022-11-14 21:17:08 -05:00
Shadowfacts 603e989879 Fix error when server responds with rich cards 2022-11-14 19:39:18 -05:00
69 changed files with 2098 additions and 637 deletions

View File

@ -1,5 +1,37 @@
# 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

View File

@ -167,5 +167,12 @@ 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"
}
} }
} }

View File

@ -79,5 +79,6 @@ extension Card {
case link case link
case photo case photo
case video case video
case rich
} }
} }

View File

@ -17,11 +17,12 @@ 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 return lhs.id == rhs.id && lhs.title == rhs.title
} }
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]> {

View File

@ -11,7 +11,9 @@ 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?)
} }

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
public class StatusState: Equatable, Hashable { public class StatusState: Equatable {
public var collapsible: Bool? public var collapsible: Bool?
public var collapsed: Bool? public var collapsed: Bool?

View File

@ -200,10 +200,8 @@
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 */; };
@ -270,6 +268,10 @@
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 */; };
@ -562,10 +564,8 @@
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,6 +632,10 @@
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>"; };
@ -924,6 +928,7 @@
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>";
@ -1178,7 +1183,7 @@
D6A3BC822321F69400FD64D5 /* Account List */ = { D6A3BC822321F69400FD64D5 /* Account List */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6A3BC832321F6C100FD64D5 /* AccountListTableViewController.swift */, D6D12B59292D684600D528E1 /* AccountListViewController.swift */,
); );
path = "Account List"; path = "Account List";
sourceTree = "<group>"; sourceTree = "<group>";
@ -1188,6 +1193,7 @@
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>";
@ -1195,7 +1201,7 @@
D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */ = { D6A3BC8C2321FF9B00FD64D5 /* Status Action Account List */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6A3BC8D2321FFB900FD64D5 /* StatusActionAccountListTableViewController.swift */, D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */,
); );
path = "Status Action Account List"; path = "Status Action Account List";
sourceTree = "<group>"; sourceTree = "<group>";
@ -1613,7 +1619,7 @@
TargetAttributes = { TargetAttributes = {
D6D4DDCB212518A000E1C4BB = { D6D4DDCB212518A000E1C4BB = {
CreatedOnToolsVersion = 10.0; CreatedOnToolsVersion = 10.0;
LastSwiftMigration = 1200; LastSwiftMigration = 1410;
}; };
D6D4DDDF212518A200E1C4BB = { D6D4DDDF212518A200E1C4BB = {
CreatedOnToolsVersion = 10.0; CreatedOnToolsVersion = 10.0;
@ -1832,6 +1838,7 @@
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 */,
@ -1851,7 +1858,6 @@
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 */,
@ -1870,7 +1876,6 @@
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 */,
@ -1947,6 +1952,7 @@
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 */,
@ -2029,11 +2035,13 @@
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;
@ -2187,7 +2195,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 = 45; CURRENT_PROJECT_VERSION = 46;
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;
@ -2255,7 +2263,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 = 45; CURRENT_PROJECT_VERSION = 46;
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;
@ -2405,7 +2413,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 = 45; CURRENT_PROJECT_VERSION = 46;
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;
@ -2434,7 +2442,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 = 45; CURRENT_PROJECT_VERSION = 46;
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;
@ -2544,7 +2552,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 = 45; CURRENT_PROJECT_VERSION = 46;
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;
@ -2571,7 +2579,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 = 45; CURRENT_PROJECT_VERSION = 46;
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;

View File

@ -99,6 +99,11 @@
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 = ""

View File

@ -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)
NotificationCenter.default.post(name: .listsChanged, object: nil) mastodonController.addedList(list)
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,7 +64,3 @@ class CreateListService {
} }
} }
extension Foundation.Notification.Name {
static let listsChanged = Notification.Name("listsChanged")
}

View File

@ -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)
NotificationCenter.default.post(name: .listsChanged, object: nil) mastodonController.deletedList(list)
} 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))

View File

@ -11,15 +11,18 @@ 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(set) var instanceType = InstanceType.mastodon private var instanceType: InstanceType = .mastodon(.vanilla, nil)
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 {
instanceType == .hometown || instanceType == .glitch switch instanceType {
case .mastodon(.hometown(_), _), .mastodon(.glitch, _):
return true
default:
return false
}
} }
var mastodonAttachmentRestrictions: Bool { var mastodonAttachmentRestrictions: Bool {
@ -27,15 +30,20 @@ struct InstanceFeatures {
} }
var pollsAndAttachments: Bool { var pollsAndAttachments: Bool {
instanceType == .pleroma instanceType.isPleroma
} }
var boostToOriginalAudience: Bool { var boostToOriginalAudience: Bool {
instanceType == .pleroma || instanceType.isMastodon instanceType.isPleroma || instanceType.isMastodon
} }
var profilePinnedStatuses: Bool { var profilePinnedStatuses: Bool {
instanceType != .pixelfed switch instanceType {
case .pixelfed:
return false
default:
return true
}
} }
var trends: Bool { var trends: Bool {
@ -48,45 +56,73 @@ struct InstanceFeatures {
var reblogVisibility: Bool { var reblogVisibility: Bool {
(instanceType.isMastodon && hasVersion(2, 8, 0)) (instanceType.isMastodon && hasVersion(2, 8, 0))
|| (instanceType == .pleroma && hasPleromaVersion(2, 0, 0)) || (instanceType.isPleroma && hasPleromaVersion(2, 0, 0))
} }
var probablySupportsMarkdown: Bool { var probablySupportsMarkdown: Bool {
instanceType == .pleroma || instanceType == .glitch || instanceType == .hometown switch instanceType {
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _):
return true
default:
return false
}
}
var needsLocalOnlyEmojiHack: Bool {
if case .mastodon(.glitch, _) = instanceType {
return true
} else {
return false
}
}
var needsWideColorGamutHack: Bool {
if case .mastodon(_, .some(let version)) = instanceType {
return version < Version(4, 0, 0)
} else {
return true
}
} }
mutating func update(instance: Instance, nodeInfo: NodeInfo?) { 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 = .glitch instanceType = .mastodon(.glitch, Version(string: ver))
} else if nodeInfo?.software.name == "hometown" { } else if nodeInfo?.software.name == "hometown" {
instanceType = .hometown var mastoVersion: Version?
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 {
version = Version(string: String(parts[1])) mastoVersion = 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") {
instanceType = .pleroma var pleromaVersion: Version?
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 instanceType = .mastodon(.vanilla, Version(string: ver))
} }
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 let version { if case .mastodon(_, .some(let version)) = instanceType {
return version >= Version(major, minor, patch) return version >= Version(major, minor, patch)
} else { } else {
return false return false
@ -94,30 +130,47 @@ struct InstanceFeatures {
} }
func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool { func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
if let pleromaVersion { switch instanceType {
return pleromaVersion >= Version(major, minor, patch) case .pleroma(.vanilla(.some(let version))), .pleroma(.akkoma(.some(let version))):
} else { return version >= Version(major, minor, patch)
default:
return false return false
} }
} }
} }
extension InstanceFeatures { extension InstanceFeatures {
enum InstanceType: Equatable { enum InstanceType {
case mastodon // vanilla case mastodon(MastodonType, Version?)
case pleroma case pleroma(PleromaType)
case hometown
case glitch
case pixelfed case pixelfed
var isMastodon: Bool { var isMastodon: Bool {
switch self { if case .mastodon(_, _) = self {
case .mastodon, .hometown, .glitch:
return true return true
default: } else {
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?)
} }
} }

View File

@ -46,6 +46,7 @@ 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]()
@ -119,6 +120,15 @@ class MastodonController: ObservableObject {
}) })
} }
func initialize() async throws {
async let ownAccount = try getOwnAccount()
async let ownInstance = try getOwnInstance()
_ = try await (ownAccount, ownInstance)
loadLists()
}
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) { func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
if account != nil { if account != nil {
completion?(.success(account)) completion?(.success(account))
@ -264,4 +274,53 @@ class MastodonController: ObservableObject {
} }
} }
private func loadLists() {
let req = Client.getLists()
run(req) { response in
if case .success(let lists, _) = response {
DispatchQueue.main.async {
self.lists = lists.sorted(using: ListComparator())
}
}
}
}
@MainActor
func addedList(_ list: List) {
var new = self.lists
new.append(list)
new.sort { $0.title < $1.title }
self.lists = new
}
@MainActor
func deletedList(_ list: List) {
self.lists.removeAll(where: { $0.id == list.id })
}
@MainActor
func renamedList(_ list: List) {
var new = self.lists
if let index = new.firstIndex(where: { $0.id == list.id }) {
new[index] = list
}
new.sort(using: ListComparator())
self.lists = new
}
}
private struct ListComparator: SortComparator {
typealias Compared = List
var underlying = String.Comparator(options: .caseInsensitive)
var order: SortOrder {
get { underlying.order }
set { underlying.order = newValue }
}
func compare(_ lhs: List, _ rhs: List) -> ComparisonResult {
return underlying.compare(lhs.title, rhs.title)
}
} }

View File

@ -39,7 +39,7 @@ class PostService: ObservableObject {
let sensitive = contentWarning != nil let sensitive = contentWarning != nil
let request = Client.createStatus( let request = Client.createStatus(
text: draft.textForPosting(on: mastodonController.instanceFeatures), text: textForPosting(),
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 { result in attachment.data.getData(features: mastodonController.instanceFeatures) { result in
switch result { switch result {
case let .success(res): case let .success(res):
continuation.resume(returning: res) continuation.resume(returning: res)
@ -104,6 +104,19 @@ 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)

View File

@ -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)
NotificationCenter.default.post(name: .listRenamed, object: list.id, userInfo: ["list": list]) mastodonController.renamedList(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,7 +63,3 @@ class RenameListService {
} }
} }
extension Foundation.Notification.Name {
static let listRenamed = Notification.Name("listRenamed")
}

View File

@ -51,7 +51,7 @@ enum CompositionAttachmentData {
} }
} }
func getData(completion: @escaping (Result<(Data, UTType), Error>) -> Void) { func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
switch self { 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,21 +71,26 @@ 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 = image.colorSpace?.name != CGColorSpace.sRGB let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG // 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 sRGB = CGColorSpace(name: CGColorSpace.sRGB)! let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
if dataUTI == "public.png" { if dataUTI == "public.png" {
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: sRGB)! data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)!
utType = .png utType = .png
} else { } else {
data = context.jpegRepresentation(of: image, colorSpace: sRGB)! data = context.jpegRepresentation(of: image, colorSpace: colorSpace)!
utType = .jpeg utType = .jpeg
} }
} else { } else {

View File

@ -86,19 +86,6 @@ 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 {

View File

@ -50,6 +50,7 @@ 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
@ -91,6 +92,7 @@ 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)
@ -131,6 +133,7 @@ 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 {
@ -181,6 +184,7 @@ 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

View File

@ -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 {
controller.getOwnAccount() try? await controller.initialize()
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)

View File

@ -50,8 +50,9 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
} }
session.mastodonController = controller session.mastodonController = controller
controller.getOwnAccount() Task {
controller.getOwnInstance() try? await controller.initialize()
}
let composeVC = ComposeHostingController(draft: draft, mastodonController: controller) let composeVC = ComposeHostingController(draft: draft, mastodonController: controller)
composeVC.delegate = self composeVC.delegate = self

View File

@ -95,8 +95,15 @@ 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 {
return UserActivityManager.mainSceneActivity(accountID: mastodonController.accountInfo!.id) if let vcActivity = rootViewController?.stateRestorationActivity() {
vcActivity.isStateRestorationActivity = true
stateRestorationLogger.info("MainSceneDelegate returning stateRestorationActivity of type \(vcActivity.activityType, privacy: .public) from VC")
return vcActivity
} else {
// need to have an activity to make sure the same account is used
return UserActivityManager.mainSceneActivity(accountID: mastodonController.accountInfo!.id)
}
} else { } else {
return nil return nil
} }
@ -144,7 +151,6 @@ 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 {
@ -162,9 +168,12 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
activateAccount(account, animated: false) activateAccount(account, animated: false)
if let activity = launchActivity, if let activity = launchActivity {
activity.activityType != UserActivityType.mainScene.rawValue { if activity.isStateRestorationActivity {
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!)) rootViewController?.restoreActivity(activity)
} else if activity.activityType != UserActivityType.mainScene.rawValue {
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!))
}
} }
} else { } else {
window!.rootViewController = createOnboardingUI() window!.rootViewController = createOnboardingUI()
@ -203,8 +212,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
func createAppUI() -> TuskerRootViewController { func createAppUI() -> TuskerRootViewController {
let mastodonController = window!.windowScene!.session.mastodonController! let mastodonController = window!.windowScene!.session.mastodonController!
mastodonController.getOwnAccount() Task {
mastodonController.getOwnInstance() try? await mastodonController.initialize()
}
let split = MainSplitViewController(mastodonController: mastodonController) let split = MainSplitViewController(mastodonController: mastodonController)
if UIDevice.current.userInterfaceIdiom == .phone, if UIDevice.current.userInterfaceIdiom == .phone,

View File

@ -1,75 +0,0 @@
//
// AccountListTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 9/5/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
class AccountListTableViewController: EnhancedTableViewController {
private let accountCell = "accountCell"
let mastodonController: MastodonController
let accountIDs: [String]
init(accountIDs: [String], mastodonController: MastodonController) {
self.accountIDs = accountIDs
self.mastodonController = mastodonController
super.init(style: .grouped)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
dragEnabled = true
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell)
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 66
tableView.alwaysBounceVertical = true
tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: CGFloat.leastNormalMagnitude))
}
// MARK: - Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return accountIDs.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: accountCell, for: indexPath) as? AccountTableViewCell else { fatalError() }
let id = accountIDs[indexPath.row]
cell.delegate = self
cell.updateUI(accountID: id)
return cell
}
}
extension AccountListTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension AccountListTableViewController: ToastableViewController {
}
extension AccountListTableViewController: MenuActionProvider {
}

View File

@ -0,0 +1,120 @@
//
// AccountListViewController.swift
// Tusker
//
// Created by Shadowfacts on 9/5/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
class AccountListViewController: UIViewController {
typealias Item = String
private let mastodonController: MastodonController
private let accountIDs: [String]
private var collectionView: UICollectionView {
view as! UICollectionView
}
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
init(accountIDs: [String], mastodonController: MastodonController) {
self.mastodonController = mastodonController
self.accountIDs = accountIDs
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
let config = UICollectionLayoutListConfiguration(appearance: .grouped)
let layout = UICollectionViewCompositionalLayout.list(using: config)
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
dataSource = createDataSource()
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, item in
cell.delegate = self
cell.updateUI(accountID: item)
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: itemIdentifier)
}
}
override func viewDidLoad() {
super.viewDidLoad()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.accounts])
snapshot.appendItems(accountIDs)
dataSource.apply(snapshot, animatingDifferences: false)
}
}
extension AccountListViewController {
enum Section {
case accounts
}
}
extension AccountListViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if let id = dataSource.itemIdentifier(for: indexPath) {
selected(account: id)
}
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let id = dataSource.itemIdentifier(for: indexPath),
let cell = collectionView.cellForItem(at: indexPath) else {
return nil
}
return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} actionProvider: { _ in
UIMenu(children: self.actionsForProfile(accountID: id, sourceView: cell))
}
}
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
}
}
extension AccountListViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let id = dataSource.itemIdentifier(for: indexPath),
let currentAccountID = mastodonController.accountInfo?.id,
let account = mastodonController.persistentContainer.account(for: id) else {
return []
}
let provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: id, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)]
}
}
extension AccountListViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension AccountListViewController: MenuActionProvider {
}
extension AccountListViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
collectionView.scrollToTop()
return .stop
}
}

View File

@ -46,30 +46,39 @@ class AssetCollectionsListViewController: UITableViewController {
}) })
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.system, .albums, .smartAlbums]) snapshot.appendSections([.system, .albums, .sharedAlbums, .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 && collection.assetCollectionSubtype != .smartAlbumRecentlyAdded else { guard collection.assetCollectionSubtype != .smartAlbumAllHidden 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 {
albumItems.append(.album(collection)) if collection.assetCollectionSubtype == .albumCloudShared {
sharedItems.append(.album(collection))
} else {
albumItems.append(.album(collection))
}
} }
} }
albumItems.sort(by: { $0.title < $1.title })
sharedItems.sort(by: { $0.title < $1.title })
snapshot.appendItems(albumItems, toSection: .albums) snapshot.appendItems(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
@ -103,6 +112,7 @@ 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 {
@ -118,15 +128,26 @@ 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? {
let currentSnapshot = snapshot() switch sectionIdentifier(for: section) {
if currentSnapshot.indexOfSection(.albums) == section { case .albums:
return NSLocalizedString("Albums", comment: "albums section title") return NSLocalizedString("Albums", comment: "albums section title")
} else if currentSnapshot.indexOfSection(.smartAlbums) == section { case .sharedAlbums:
return NSLocalizedString("Shared Albums", comment: "shared albums section title")
case .smartAlbums:
return NSLocalizedString("Smart Albums", comment: "smart albums section title") return NSLocalizedString("Smart Albums", comment: "smart albums section title")
} else { default:
return nil return nil
} }
} }

View File

@ -15,6 +15,7 @@ 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
@ -90,7 +91,7 @@ struct ComposeAttachmentRow: View {
mode = .recognizingText mode = .recognizingText
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
self.attachment.data.getData { (result) in self.attachment.data.getData(features: mastodonController.instanceFeatures, skipAllConversion: true) { (result) in
let data: Data let data: Data
do { do {
try data = result.get().0 try data = result.get().0

View File

@ -51,9 +51,20 @@ struct ComposePollView: View {
.hoverEffect() .hoverEffect()
} }
ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in List {
ComposePollOption(poll: poll, option: e.element, optionIndex: e.offset) ForEach(Array(poll.options.enumerated()), id: \.element.id) { (e) in
ComposePollOption(poll: poll, option: e.element, optionIndex: e.offset)
.frame(height: 36)
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.onMove { indices, newIndex in
poll.options.move(fromOffsets: indices, toOffset: newIndex)
}
} }
.listStyle(.plain)
.frame(height: 44 * CGFloat(poll.options.count))
Button(action: self.addOption) { Button(action: self.addOption) {
Label("Add Option", systemImage: "plus") Label("Add Option", systemImage: "plus")

View File

@ -42,18 +42,20 @@ 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
} }
@ -61,7 +63,6 @@ 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
} }
@ -107,6 +108,8 @@ 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))
} }
} }
@ -135,6 +138,17 @@ struct ComposeView: View {
} }
} }
@available(iOS, obsoleted: 16.0)
private var keyboardInset: CGFloat {
if #unavailable(iOS 16.0),
UIDevice.current.userInterfaceIdiom == .pad,
keyboardReader.isVisible {
return 44
} else {
return 0
}
}
@ViewBuilder @ViewBuilder
private var autocompleteSuggestions: some View { private var autocompleteSuggestions: some View {
if let state = uiState.autocompleteState { if let state = uiState.autocompleteState {
@ -161,7 +175,7 @@ struct ComposeView: View {
if draft.contentWarningEnabled { if draft.contentWarningEnabled {
ComposeEmojiTextField( ComposeEmojiTextField(
text: $draft.contentWarning, text: $uiState.draft.contentWarning,
placeholder: "Write your warning here", placeholder: "Write your warning here",
becomeFirstResponder: $contentWarningBecomeFirstResponder, becomeFirstResponder: $contentWarningBecomeFirstResponder,
focusNextView: $mainComposeTextViewBecomeFirstResponder focusNextView: $mainComposeTextViewBecomeFirstResponder
@ -316,6 +330,26 @@ private struct GlobalFrameOutsideListPrefKey: PreferenceKey {
} }
} }
@available(iOS, obsoleted: 16.0)
private class KeyboardReader: ObservableObject {
@Published var isVisible = false
init() {
NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(willHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}
@objc func willShow(_ notification: Foundation.Notification) {
// when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible"
let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
isVisible = endFrame.height > 72
}
@objc func willHide() {
isVisible = false
}
}
//struct ComposeView_Previews: PreviewProvider { //struct ComposeView_Previews: PreviewProvider {
// static var previews: some View { // static var previews: some View {
// ComposeView() // ComposeView()

View File

@ -79,6 +79,7 @@ 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 {
@ -101,6 +102,7 @@ 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

View File

@ -24,6 +24,8 @@ 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
@ -70,9 +72,10 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(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) {
@ -141,7 +144,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.instanceType.isMastodon, if mastodonController.instanceFeatures.trends,
!Preferences.shared.hideDiscover { !Preferences.shared.hideDiscover {
addDiscoverSection(to: &snapshot) addDiscoverSection(to: &snapshot)
} }
@ -158,7 +161,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() reloadLists(mastodonController.lists)
} }
private func addDiscoverSection(to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) { private func addDiscoverSection(to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
@ -172,7 +175,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.instanceType.isMastodon, if mastodonController.instanceFeatures.trends,
!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)
@ -180,39 +183,13 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
self.dataSource.apply(snapshot) self.dataSource.apply(snapshot)
} }
@objc private func reloadLists() { private func reloadLists(_ lists: [List]) {
let request = Client.getLists() var snapshot = self.dataSource.snapshot()
mastodonController.run(request) { (response) in snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .lists))
guard case let .success(lists, _) = response else { snapshot.appendItems(lists.map { .list($0) }, toSection: .lists)
return snapshot.appendItems([.addList], toSection: .lists)
}
var snapshot = self.dataSource.snapshot() self.dataSource.apply(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

View File

@ -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 {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() let 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,6 +122,27 @@ 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

View File

@ -14,6 +14,7 @@ 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()
} }
@ -29,17 +30,14 @@ 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
@ -109,11 +107,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)
@ -144,6 +142,7 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
// some SO posts indicate that just sharing a URL to the video should work, but that may need to be a local URL? // 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

View File

@ -17,17 +17,16 @@ 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()
@ -86,9 +85,13 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
super.viewDidLoad() super.viewDidLoad()
setupContentView() setupContentView()
setupControls()
setControlsVisible(initialControlsVisible, animated: false) setControlsVisible(initialControlsVisible, animated: false)
shareButton.isEnabled = !contentView.activityItemsForSharing.isEmpty if contentView.activityItemsForSharing.isEmpty {
shareContainer.isUserInteractionEnabled = false
shareImage.tintColor = .systemGray
}
scrollView.delegate = self scrollView.delegate = self
@ -103,15 +106,19 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
dismissInteractionController = LargeImageInteractionController(viewController: self) dismissInteractionController = LargeImageInteractionController(viewController: self)
} }
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(scrollViewPressed(_:)))) let singleTap = UITapGestureRecognizer(target: self, action: #selector(scrollViewPressed(_:)))
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(scrollViewDoubleTapped(_:))) 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)
@ -122,6 +129,62 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
]) ])
} }
private func setupControls() {
shareContainer = UIView()
shareContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(sharePressed)))
shareContainer.translatesAutoresizingMaskIntoConstraints = false
topControlsView.addSubview(shareContainer)
shareImage = UIImageView(image: UIImage(systemName: "square.and.arrow.up"))
shareImage.tintColor = .white
shareImage.contentMode = .scaleAspectFit
shareImage.translatesAutoresizingMaskIntoConstraints = false
shareContainer.addSubview(shareImage)
shareButtonTopConstraint = shareImage.topAnchor.constraint(greaterThanOrEqualTo: shareContainer.topAnchor)
shareButtonLeadingConstraint = shareImage.leadingAnchor.constraint(greaterThanOrEqualTo: shareContainer.leadingAnchor)
NSLayoutConstraint.activate([
shareContainer.topAnchor.constraint(equalTo: topControlsView.topAnchor),
shareContainer.leadingAnchor.constraint(equalTo: topControlsView.leadingAnchor),
shareContainer.bottomAnchor.constraint(equalTo: topControlsView.bottomAnchor),
shareContainer.widthAnchor.constraint(greaterThanOrEqualToConstant: 50),
shareContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: 50),
shareImage.centerXAnchor.constraint(equalTo: shareContainer.centerXAnchor),
shareImage.centerYAnchor.constraint(equalTo: shareContainer.centerYAnchor),
shareButtonTopConstraint,
shareButtonLeadingConstraint,
shareImage.widthAnchor.constraint(equalToConstant: 24),
shareImage.heightAnchor.constraint(equalToConstant: 24),
])
let closeContainer = UIView()
closeContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(closeButtonPressed)))
closeContainer.translatesAutoresizingMaskIntoConstraints = false
topControlsView.addSubview(closeContainer)
let closeImage = UIImageView(image: UIImage(systemName: "xmark"))
closeImage.tintColor = .white
closeImage.contentMode = .scaleAspectFit
closeImage.translatesAutoresizingMaskIntoConstraints = false
closeContainer.addSubview(closeImage)
closeButtonTopConstraint = closeImage.topAnchor.constraint(greaterThanOrEqualTo: closeContainer.topAnchor)
closeButtonTrailingConstraint = closeContainer.trailingAnchor.constraint(greaterThanOrEqualTo: closeImage.trailingAnchor)
NSLayoutConstraint.activate([
closeContainer.topAnchor.constraint(equalTo: topControlsView.topAnchor),
closeContainer.trailingAnchor.constraint(equalTo: topControlsView.trailingAnchor),
closeContainer.bottomAnchor.constraint(equalTo: closeContainer.bottomAnchor),
closeContainer.widthAnchor.constraint(greaterThanOrEqualToConstant: 50),
closeContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: 50),
closeImage.centerXAnchor.constraint(equalTo: closeContainer.centerXAnchor),
closeImage.centerYAnchor.constraint(equalTo: closeContainer.centerYAnchor),
closeButtonTopConstraint,
closeButtonTrailingConstraint,
closeImage.widthAnchor.constraint(equalToConstant: 24),
closeImage.heightAnchor.constraint(equalToConstant: 24),
])
}
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
@ -152,7 +215,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 - shareButton.bounds.width) / 2 let offset = (earWidth - shareImage.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) {
@ -271,7 +334,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 = shareButton activityVC.popoverPresentationController?.sourceView = shareImage
present(activityVC, animated: true) present(activityVC, animated: true)
} }

View File

@ -11,15 +11,8 @@
<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>
@ -33,45 +26,8 @@
<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" translatesAutoresizingMaskIntoConstraints="NO" id="kHo-B9-R7a"> <view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kHo-B9-R7a">
<rect key="frame" x="0.0" y="0.0" width="375" height="36"/> <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"/>
@ -110,8 +66,4 @@
<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>

View File

@ -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, owner: self) content = LargeImageImageContentView(image: transformedImage)
} else { } else {
content = LargeImageImageContentView(image: image, owner: self) content = LargeImageImageContentView(image: image)
} }
} }
@ -167,7 +167,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: source) { let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: source) {
image = grayscale image = grayscale
} }
setContent(LargeImageImageContentView(image: image, owner: self)) setContent(LargeImageImageContentView(image: image))
} }
} }

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import Combine
class EditListAccountsViewController: EnhancedTableViewController { class EditListAccountsViewController: EnhancedTableViewController {
@ -22,6 +23,8 @@ 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
@ -30,7 +33,13 @@ class EditListAccountsViewController: EnhancedTableViewController {
listChanged() listChanged()
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: list.id) listRenamedCancellable = mastodonController.$lists
.compactMap { $0.first { $0.id == list.id } }
.removeDuplicates(by: { $0.title == $1.title })
.sink { [unowned self] in
self.list = $0
self.listChanged()
}
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -88,12 +97,6 @@ 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)

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import Combine
class ListTimelineViewController: TimelineViewController { class ListTimelineViewController: TimelineViewController {
@ -15,6 +16,8 @@ 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
@ -22,7 +25,13 @@ class ListTimelineViewController: TimelineViewController {
listChanged() listChanged()
NotificationCenter.default.addObserver(self, selector: #selector(listRenamed(_:)), name: .listRenamed, object: list.id) listRenamedCancellable = mastodonController.$lists
.compactMap { $0.first { $0.id == list.id } }
.removeDuplicates(by: { $0.title == $1.title })
.sink { [unowned self] in
self.list = $0
self.listChanged()
}
} }
required init?(coder aDecoder: NSCoder) { required init?(coder aDecoder: NSCoder) {
@ -40,6 +49,7 @@ class ListTimelineViewController: TimelineViewController {
if presentEditOnAppear { if presentEditOnAppear {
presentEdit(animated: animated) presentEdit(animated: animated)
presentEditOnAppear = false
} }
} }
@ -47,12 +57,6 @@ 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))

View File

@ -87,6 +87,16 @@ 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()

View File

@ -11,6 +11,14 @@ 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()
} }

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import Combine
protocol MainSidebarViewControllerDelegate: AnyObject { protocol MainSidebarViewControllerDelegate: AnyObject {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
@ -28,6 +29,8 @@ 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),
@ -99,10 +102,11 @@ 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?()
} }
@ -163,14 +167,14 @@ class MainSidebarViewController: UIViewController {
snapshot.appendItems([ snapshot.appendItems([
.tab(.compose) .tab(.compose)
], toSection: .compose) ], toSection: .compose)
if mastodonController.instanceFeatures.instanceType.isMastodon, if mastodonController.instanceFeatures.trends,
!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() reloadLists(mastodonController.lists)
reloadSavedHashtags() reloadSavedHashtags()
reloadSavedInstances() reloadSavedInstances()
} }
@ -188,7 +192,7 @@ class MainSidebarViewController: UIViewController {
} }
private func ownInstanceLoaded(_ instance: Instance) { private func ownInstanceLoaded(_ instance: Instance) {
if mastodonController.instanceFeatures.instanceType.isMastodon { if mastodonController.instanceFeatures.trends {
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])
@ -203,42 +207,28 @@ class MainSidebarViewController: UIViewController {
} }
} }
@objc private func reloadLists() { private func reloadLists(_ lists: [List]) {
let request = Client.getLists() var exploreSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
mastodonController.run(request) { [weak self] (response) in exploreSnapshot.append([.listsHeader])
guard let self = self, case let .success(lists, _) = response else { return } exploreSnapshot.expand([.listsHeader])
exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader)
var exploreSnapshot = NSDiffableDataSourceSectionSnapshot<Item>() exploreSnapshot.append([.addList], to: .listsHeader)
exploreSnapshot.append([.listsHeader]) var selectedItem: Item?
exploreSnapshot.expand([.listsHeader]) if let selectedIndexPath = collectionView.indexPathsForSelectedItems?.first,
exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader) let item = dataSource.itemIdentifier(for: selectedIndexPath) {
exploreSnapshot.append([.addList], to: .listsHeader) if case .list(let list) = item,
DispatchQueue.main.async { let newList = lists.first(where: { $0.id == list.id }) {
let selected = self.collectionView.indexPathsForSelectedItems?.first selectedItem = .list(newList)
} else {
self.dataSource.apply(exploreSnapshot, to: .lists) { selectedItem = item
if let selected = selected {
self.collectionView.selectItem(at: selected, animated: false, scrollPosition: .centeredVertically)
}
}
} }
} }
}
@objc private func listRenamed(_ notification: Foundation.Notification) { self.dataSource.apply(exploreSnapshot, to: .lists) {
let list = notification.userInfo!["list"] as! List if let selectedItem,
var snapshot = dataSource.snapshot() let indexPath = self.dataSource.indexPath(for: selectedItem) {
let existing = snapshot.itemIdentifiers(inSection: .lists).first(where: { self.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredVertically)
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)
} }
} }

View File

@ -83,6 +83,14 @@ 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
@ -378,6 +386,36 @@ 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()
} }

View File

@ -233,6 +233,30 @@ 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()
} }

View File

@ -9,6 +9,8 @@
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?

View File

@ -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 or Hometown).\n" var s: AttributedString = "This option is only supported with Pleroma and some compatible Mastodon instances (such as Glitch).\n"
if let account = LocalData.shared.getMostRecentAccount() { 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

View File

@ -17,6 +17,7 @@ struct ComposingPrefsView: View {
visibilitySection visibilitySection
composingSection composingSection
replyingSection replyingSection
writingSection
} }
.listStyle(InsetGroupedListStyle()) .listStyle(InsetGroupedListStyle())
.navigationBarTitle("Composing") .navigationBarTitle("Composing")
@ -76,6 +77,14 @@ struct ComposingPrefsView: View {
} }
} }
var writingSection: some View {
Section {
Toggle(isOn: $preferences.useTwitterKeyboard) {
Text("Show @ and # on Keyboard")
}
}
}
} }
struct ComposingPrefsView_Previews: PreviewProvider { struct ComposingPrefsView_Previews: PreviewProvider {

View File

@ -61,6 +61,13 @@ class ProfileHeaderCollectionViewCell: UICollectionViewCell {
} }
} }
// overrides an internal method
// when the super impl is used, preferredLayoutAttributesFitting(_:) isn't called while the view is offscreen (i.e., window == nil)
// and so the collection view imposes a height of 44pts which breaks the layout
@objc func _preferredLayoutAttributesFittingAttributes(_ attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
return preferredLayoutAttributesFitting(attributes)
}
enum State { enum State {
case unloaded case unloaded
case placeholder(heightConstraint: NSLayoutConstraint) case placeholder(heightConstraint: NSLayoutConstraint)

View File

@ -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 = await controller.state { if case .notLoadedInitial = controller.state {
await load() await load()
} }
} }

View File

@ -1,163 +0,0 @@
//
// StatusActionAccountListTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 9/5/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class StatusActionAccountListTableViewController: EnhancedTableViewController {
private let statusCell = "statusCell"
private let accountCell = "accountCell"
weak var mastodonController: MastodonController!
let actionType: ActionType
let statusID: String
var statusState: StatusState
var accountIDs: [String]? {
didSet {
tableView.reloadData()
}
}
/// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate.
var showInacurateCountWarning = false
/**
Creates a new view controller showing the accounts that performed the given action on the given status.
- Parameter actionType The action that this VC is for.
- Parameter statusID The ID of the status to show.
- Parameter accountIDs The accounts that will be shown. If `nil` is passed, a request will be performed to load the accounts.
- Parameter mastodonController The `MastodonController` instance this view controller uses.
*/
init(actionType: ActionType, statusID: String, statusState: StatusState, accountIDs: [String]?, mastodonController: MastodonController) {
self.mastodonController = mastodonController
self.actionType = actionType
self.statusID = statusID
self.statusState = statusState
self.accountIDs = accountIDs
super.init(style: .grouped)
dragEnabled = true
switch actionType {
case .favorite:
title = NSLocalizedString("Favorited By", comment: "status favorited by accounts list title")
case .reblog:
title = NSLocalizedString("Reblogged By", comment: "status reblogged by accounts list title")
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell)
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell)
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 66 // height of account cell, which will be the most common
tableView.alwaysBounceVertical = true
tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: CGFloat.leastNormalMagnitude))
if accountIDs == nil {
// account IDs haven't been set, so perform a request to load them
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError("Missing cached status \(statusID)")
}
tableView.tableFooterView = UIActivityIndicatorView(style: .large)
let request = actionType == .favorite ? Status.getFavourites(status.id) : Status.getReblogs(status.id)
mastodonController.run(request) { (response) in
guard case let .success(accounts, _) = response else { fatalError() }
self.mastodonController.persistentContainer.addAll(accounts: accounts) {
DispatchQueue.main.async {
self.accountIDs = accounts.map { $0.id }
self.tableView.tableFooterView = nil
}
}
}
}
}
// MARK: - Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
return 2
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch section {
case 0: // status
return 1
case 1: // accounts
if let accountIDs = accountIDs {
return accountIDs.count
} else {
return 0
}
default:
fatalError("Invalid section \(section)")
}
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch indexPath.section {
case 0:
guard let cell = tableView.dequeueReusableCell(withIdentifier: statusCell, for: indexPath) as? TimelineStatusTableViewCell else { fatalError() }
cell.delegate = self
cell.updateUI(statusID: statusID, state: statusState)
return cell
case 1:
guard let accountIDs = accountIDs,
let cell = tableView.dequeueReusableCell(withIdentifier: accountCell, for: indexPath) as? AccountTableViewCell else { fatalError() }
cell.delegate = self
cell.updateUI(accountID: accountIDs[indexPath.row])
return cell
default:
fatalError("Invalid section \(indexPath.section)")
}
}
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
guard section == 1, showInacurateCountWarning else { return nil }
return NSLocalizedString("Favorite and reblog counts for posts originating from instances other than your own may not be accurate.", comment: "shown on lists of status total actions")
}
enum ActionType {
case favorite, reblog
}
}
extension StatusActionAccountListTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension StatusActionAccountListTableViewController: ToastableViewController {
}
extension StatusActionAccountListTableViewController: MenuActionProvider {
}
extension StatusActionAccountListTableViewController: StatusTableViewCellDelegate {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
// causes the table view to recalculate the cell heights
tableView.beginUpdates()
tableView.endUpdates()
}
}

View File

@ -0,0 +1,266 @@
//
// StatusActionAccountListViewController.swift
// Tusker
//
// Created by Shadowfacts on 9/5/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class StatusActionAccountListViewController: UIViewController {
private let mastodonController: MastodonController
private let actionType: ActionType
private let statusID: String
private let statusState: StatusState
private var accountIDs: [String]?
/// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate.
var showInacurateCountWarning = false
private var collectionView: UICollectionView {
view as! UICollectionView
}
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
/**
Creates a new view controller showing the accounts that performed the given action on the given status.
- Parameter actionType The action that this VC is for.
- Parameter statusID The ID of the status to show.
- Parameter accountIDs The accounts that will be shown. If `nil` is passed, a request will be performed to load the accounts.
- Parameter mastodonController The `MastodonController` instance this view controller uses.
*/
init(actionType: ActionType, statusID: String, statusState: StatusState, accountIDs: [String]?, mastodonController: MastodonController) {
self.mastodonController = mastodonController
self.actionType = actionType
self.statusID = statusID
self.statusState = statusState
self.accountIDs = accountIDs
super.init(nibName: nil, bundle: nil)
switch actionType {
case .favorite:
title = NSLocalizedString("Favorited By", comment: "status favorited by accounts list title")
case .reblog:
title = NSLocalizedString("Reblogged By", comment: "status reblogged by accounts list title")
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
switch dataSource.sectionIdentifier(for: sectionIndex)! {
case .status:
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.footerMode = self.showInacurateCountWarning ? .supplementary : .none
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
}
config.trailingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
}
return NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
case .accounts:
return NSCollectionLayoutSection.list(using: .init(appearance: .grouped), layoutEnvironment: environment)
}
}
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
dataSource = createDataSource()
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, Void> { [unowned self] cell, indexPath, _ in
cell.delegate = self
cell.updateUI(statusID: self.statusID, state: self.statusState)
}
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, item in
cell.delegate = self
cell.updateUI(accountID: item)
}
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .status:
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: ())
case .account(let id):
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id)
}
}
let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionFooter) { (headerView, collectionView, indexPath) in
var config = headerView.defaultContentConfiguration()
config.text = NSLocalizedString("Favorite and reblog counts for posts originating from instances other than your own may not be accurate.", comment: "shown on lists of status total actions")
headerView.contentConfiguration = config
}
dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in
return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderCell, for: indexPath)
}
return dataSource
}
override func viewDidLoad() {
super.viewDidLoad()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.status, .accounts])
snapshot.appendItems([.status], toSection: .status)
if let accountIDs {
snapshot.appendItems(accountIDs.map { .account($0) }, toSection: .accounts)
}
dataSource.apply(snapshot, animatingDifferences: false)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if accountIDs == nil {
Task {
await loadAccounts()
}
}
}
private func loadAccounts() async {
let request: Request<[Account]>
switch actionType {
case .favorite:
request = Status.getFavourites(statusID)
case .reblog:
request = Status.getReblogs(statusID)
}
do {
// TODO: pagination
let (accounts, _) = try await mastodonController.run(request)
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(accounts: accounts) {
continuation.resume()
}
}
accountIDs = accounts.map(\.id)
var snapshot = dataSource.snapshot()
snapshot.appendItems(accounts.map { .account($0.id) }, toSection: .accounts)
dataSource.apply(snapshot, animatingDifferences: true) {}
} catch {
let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { toast in
toast.dismissToast(animated: true)
await self.loadAccounts()
}
self.showToast(configuration: config, animated: true)
}
}
}
extension StatusActionAccountListViewController {
enum ActionType {
case favorite, reblog
}
}
extension StatusActionAccountListViewController {
enum Section {
case status
case accounts
}
enum Item: Hashable {
case status
case account(String)
}
}
extension StatusActionAccountListViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
switch dataSource.itemIdentifier(for: indexPath) {
case nil:
return
case .status:
selected(status: statusID, state: statusState.copy())
case .account(let id):
selected(account: id)
}
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let item = dataSource.itemIdentifier(for: indexPath),
let cell = collectionView.cellForItem(at: indexPath) else {
return nil
}
switch item {
case .status:
return (cell as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration()
case .account(let id):
return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} actionProvider: { _ in
UIMenu(children: self.actionsForProfile(accountID: id, sourceView: cell))
}
}
}
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
}
}
extension StatusActionAccountListViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let currentAccountID = mastodonController.accountInfo?.id,
let item = dataSource.itemIdentifier(for: indexPath) else {
return []
}
let provider: NSItemProvider
switch item {
case .status:
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
return []
}
provider = NSItemProvider(object: status.url! as NSURL)
let activity = UserActivityManager.showConversationActivity(mainStatusID: statusID, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
case .account(let id):
guard let account = mastodonController.persistentContainer.account(for: id) else {
return []
}
provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
}
return [UIDragItem(itemProvider: provider)]
}
}
extension StatusActionAccountListViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension StatusActionAccountListViewController: MenuActionProvider {
}
extension StatusActionAccountListViewController: StatusCollectionViewCellDelegate {
func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) {
if let indexPath = collectionView.indexPath(for: cell) {
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!])
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
}
}
}
extension StatusActionAccountListViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
collectionView.scrollToTop()
return .stop
}
}

View File

@ -0,0 +1,165 @@
//
// TimelineGapCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 11/16/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
class TimelineGapCollectionViewCell: UICollectionViewCell {
private(set) var direction = TimelineGapDirection.above
private let indicator = UIActivityIndicatorView(style: .medium)
private let chevronView = AnimatingChevronView()
override var isHighlighted: Bool {
didSet {
backgroundColor = isHighlighted ? .systemFill : .systemGroupedBackground
}
}
var showsIndicator: Bool = false {
didSet {
if showsIndicator {
indicator.isHidden = false
indicator.startAnimating()
} else {
indicator.isHidden = true
indicator.stopAnimating()
}
}
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .systemGroupedBackground
indicator.isHidden = true
indicator.color = .tintColor
let label = UILabel()
label.text = "Load more"
label.font = .preferredFont(forTextStyle: .headline)
label.adjustsFontForContentSizeCategory = true
label.textColor = .tintColor
chevronView.update(direction: .above)
let stack = UIStackView(arrangedSubviews: [
label,
chevronView,
])
stack.axis = .horizontal
stack.spacing = 8
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
indicator.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(indicator)
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
stack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
indicator.trailingAnchor.constraint(equalTo: stack.leadingAnchor, constant: -8),
indicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
contentView.heightAnchor.constraint(equalToConstant: 44),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
update()
}
func update() {
guard let superview = superview as? UICollectionView else {
return
}
let yInParent = frame.minY - superview.contentOffset.y
let centerInParent = yInParent + bounds.height / 2
let centerOfParent = superview.frame.height / 2
let newDirection: TimelineGapDirection
if centerInParent > centerOfParent {
newDirection = .above
} else {
newDirection = .below
}
if newDirection != direction {
direction = newDirection
chevronView.update(direction: newDirection)
}
}
}
private class AnimatingChevronView: UIView {
override class var layerClass: AnyClass { CAShapeLayer.self }
var shapeLayer: CAShapeLayer { layer as! CAShapeLayer }
override var intrinsicContentSize: CGSize { CGSize(width: 20, height: 25) }
var animator: UIViewPropertyAnimator?
let upPath: CGPath = {
let path = UIBezierPath()
let width: CGFloat = 20
let height: CGFloat = 25
path.move(to: CGPoint(x: 0, y: height / 2))
path.addLine(to: CGPoint(x: width / 2, y: height / 5))
path.addLine(to: CGPoint(x: width, y: height / 2))
return path.cgPath
}()
let downPath: CGPath = {
let path = UIBezierPath()
let width: CGFloat = 20
let height: CGFloat = 25
path.move(to: CGPoint(x: 0, y: height / 2))
path.addLine(to: CGPoint(x: width / 2, y: 4 * height / 5))
path.addLine(to: CGPoint(x: width, y: height / 2))
return path.cgPath
}()
init() {
super.init(frame: .zero)
shapeLayer.fillColor = nil
shapeLayer.strokeColor = tintColor.cgColor
shapeLayer.lineCap = .round
shapeLayer.lineJoin = .round
shapeLayer.lineWidth = 3
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func tintColorDidChange() {
super.tintColorDidChange()
shapeLayer.strokeColor = tintColor.cgColor
}
func update(direction: TimelineGapDirection) {
if animator?.isRunning == true {
animator!.stopAnimation(true)
}
animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
if direction == .below {
self.shapeLayer.path = self.upPath
} else {
self.shapeLayer.path = self.downPath
}
}
animator!.startAnimation()
}
}

View File

@ -16,16 +16,15 @@ 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
var collectionView: UICollectionView { private(set) 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
@ -42,7 +41,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
override func loadView() { override func viewDidLoad() {
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()
@ -58,17 +59,24 @@ 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)
view = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView = 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")
@ -79,10 +87,15 @@ 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
}
override func viewDidLoad() { contentOffsetObservation = collectionView.observe(\.contentOffset) { [weak self] _, _ in
super.viewDidLoad() if let indexPath = self?.dataSource.indexPath(for: .gap),
let cell = self?.collectionView.cellForItem(at: indexPath) as? TimelineGapCollectionViewCell {
cell.update()
}
}
NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil)
} }
// separate method because InstanceTimelineViewController needs to be able to customize it // separate method because InstanceTimelineViewController needs to be able to customize it
@ -95,6 +108,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in 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()
@ -109,6 +125,8 @@ 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:
@ -139,9 +157,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
collectionView.deselectItem(at: $0, animated: true) collectionView.deselectItem(at: $0, animated: true)
} }
Task { if case .notLoadedInitial = controller.state {
if case .notLoadedInitial = await controller.state { if doRestore() {
await controller.loadInitial() Task {
await checkPresent()
}
} else {
Task {
await controller.loadInitial()
}
} }
} }
} }
@ -159,10 +183,106 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
} }
override func viewDidDisappear(_ animated: Bool) { func stateRestorationActivity() -> NSUserActivity? {
super.viewDidDisappear(animated) let visible = collectionView.indexPathsForVisibleItems.sorted()
let snapshot = dataSource.snapshot()
guard let currentAccountID = mastodonController.accountInfo?.id,
!visible.isEmpty,
let statusesSection = snapshot.sectionIdentifiers.firstIndex(of: .statuses),
let firstVisible = visible.first(where: { $0.section == statusesSection }),
let lastVisible = visible.last(where: { $0.section == statusesSection }) else {
return nil
}
let allItems = snapshot.itemIdentifiers(inSection: .statuses)
// pruneOffscreenRows() let startIndex = max(0, firstVisible.row - 20)
let endIndex = min(allItems.count - 1, lastVisible.row + 20)
let firstVisibleItem: Item
var items = allItems[startIndex...endIndex]
if let gapIndex = items.firstIndex(of: .gap) {
// if the gap is above the top visible item, we take everything below the gap
// otherwise, we take everything above the gap
if gapIndex <= firstVisible.row {
items = allItems[(gapIndex + 1)...endIndex]
if gapIndex == firstVisible.row {
firstVisibleItem = allItems.first!
} else {
assert(items.indices.contains(firstVisible.row))
firstVisibleItem = allItems[firstVisible.row]
}
} else {
items = allItems[startIndex..<gapIndex]
firstVisibleItem = allItems[firstVisible.row]
}
} else {
firstVisibleItem = allItems[firstVisible.row]
}
let ids = items.map {
if case .status(id: let id, state: _) = $0 {
return id
} else {
fatalError()
}
}
let firstVisibleID: String
if case .status(id: let id, state: _) = firstVisibleItem {
firstVisibleID = id
} else {
fatalError()
}
stateRestorationLogger.debug("TimelineViewController: creating state restoration activity with topID \(firstVisibleID)")
let activity = UserActivityManager.showTimelineActivity(timeline: timeline, accountID: currentAccountID)!
activity.addUserInfoEntries(from: [
"statusIDs": ids,
"topID": firstVisibleID,
])
activity.isEligibleForPrediction = false
return activity
}
func restoreActivity(_ activity: NSUserActivity) {
self.activityToRestore = activity
}
private func doRestore() -> Bool {
guard let activity = activityToRestore,
let statusIDs = activity.userInfo?["statusIDs"] as? [String] else {
stateRestorationLogger.fault("TimelineViewController: activity missing statusIDs")
return false
}
activityToRestore = nil
loadViewIfNeeded()
controller.restoreInitial {
var snapshot = dataSource.snapshot()
snapshot.appendSections([.statuses])
let items = statusIDs.map { Item.status(id: $0, state: .unknown) }
snapshot.appendItems(items, toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: false) {
if let topID = activity.userInfo?["topID"] as? String,
let index = statusIDs.firstIndex(of: topID),
let indexPath = self.dataSource.indexPath(for: items[index]) {
// it sometimes takes multiple attempts to convert on the right scroll position
// since we're dealing with a bunch of unmeasured cells, so just try a few times in a loop
var count = 0
while count < 5 {
count += 1
let origOffset = self.collectionView.contentOffset
self.collectionView.layoutIfNeeded()
self.collectionView.scrollToItem(at: indexPath, at: .top, animated: false)
let newOffset = self.collectionView.contentOffset
if abs(origOffset.y - newOffset.y) <= 1 {
break
}
}
stateRestorationLogger.fault("TimelineViewController: restored statuses with top ID \(topID)")
} else {
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find top ID")
}
}
}
return true
} }
private func removeTimelineDescriptionCell() { private func removeTimelineDescriptionCell() {
@ -172,35 +292,27 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
isShowingTimelineDescription = false isShowingTimelineDescription = false
} }
// private func pruneOffscreenRows() { @objc private func sceneWillEnterForeground(_ notification: Foundation.Notification) {
// guard let lastVisibleIndexPath = collectionView.indexPathsForVisibleItems.last else { guard let scene = notification.object as? UIScene,
// return // view.window is nil when the VC is not on screen
// } view.window?.windowScene == scene else {
// var snapshot = dataSource.snapshot() return
// guard snapshot.indexOfSection(.statuses) != nil else { }
// return Task {
// } 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 = await controller.state { if case .notLoadedInitial = controller.state {
await controller.loadInitial() await controller.loadInitial()
} else { } else {
await controller.loadNewer() // I'm not sure whether this should move into TimelineLikeController/TimelineLikeCollectionViewController
let (_, presentItems) = await (controller.loadNewer(), try? loadInitial())
if let presentItems {
insertPresentItemsIfNecessary(presentItems)
}
} }
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing() collectionView.refreshControl?.endRefreshing()
@ -208,6 +320,90 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
} }
private func checkPresent() async {
if case .idle = controller.state,
let presentItems = try? await loadInitial() {
insertPresentItemsIfNecessary(presentItems)
}
}
private func insertPresentItemsIfNecessary(_ presentItems: [String]) {
var snapshot = dataSource.snapshot()
let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
if case .status(id: let firstID, state: _) = currentItems.first,
// if there's no overlap between presentItems and the existing items in the data source, insert the present items and prompt the user
!presentItems.contains(firstID) {
// remove any existing gap, if there is one
if let index = currentItems.lastIndex(of: .gap) {
snapshot.deleteItems(Array(currentItems[index...]))
}
snapshot.insertItems([.gap], beforeItem: currentItems.first!)
snapshot.insertItems(presentItems.map { .status(id: $0, state: .unknown) }, beforeItem: .gap)
var config = ToastConfiguration(title: "Jump to present")
config.edge = .top
config.systemImageName = "arrow.up"
config.dismissAutomaticallyAfter = 4
config.action = { [unowned self] toast in
toast.dismissToast(animated: true)
self.dataSource.apply(snapshot, animatingDifferences: true) {
// TODO: we can't set prevScrollOffsetBeforeScrollToTop here to allow undoing the scroll-to-top
// because that would involve scrolling through unmeasured-cell which fucks up the content offset values.
// we probably need a data-source aware implementation of scrollToTop which uses item & offset w/in item
// to track the restore position
self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: true)
}
}
self.showToast(configuration: config, animated: true)
}
}
// NOTE: this only works when items are being inserted ABOVE the item to maintain
private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, maintainingBottomRelativeScrollPositionOf itemToMaintain: Item) {
// use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)!
snapshotView.layer.zPosition = 1000
snapshotView.frame = view.bounds
view.addSubview(snapshotView)
var firstItemAfterOriginalGapOffsetFromTop: CGFloat = 0
if let indexPath = dataSource.indexPath(for: itemToMaintain),
let cell = collectionView.cellForItem(at: indexPath) {
// subtract top safe area inset b/c scrollToItem at .top aligns the top of the cell to the top of the safe area
firstItemAfterOriginalGapOffsetFromTop = cell.convert(.zero, to: view).y - view.safeAreaInsets.top
}
dataSource.apply(snapshot, animatingDifferences: false) {
if let indexPathOfItemAfterOriginalGap = self.dataSource.indexPath(for: itemToMaintain) {
// scroll up until we've accumulated enough MEASURED height that we can put the
// firstItemAfterOriginalGapCell at the top of the screen and then scroll down by
// firstItemAfterOriginalGapOffsetFromTop without intruding into unmeasured area
var cur = indexPathOfItemAfterOriginalGap
var amountScrolledUp: CGFloat = 0
while true {
if cur.row <= 0 {
break
}
if let cell = self.collectionView.cellForItem(at: indexPathOfItemAfterOriginalGap),
cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > firstItemAfterOriginalGapOffsetFromTop {
break
}
cur = IndexPath(row: cur.row - 1, section: cur.section)
self.collectionView.scrollToItem(at: cur, at: .top, animated: false)
self.collectionView.layoutIfNeeded()
let attrs = self.collectionView.layoutAttributesForItem(at: cur)!
amountScrolledUp += attrs.size.height
}
self.collectionView.contentOffset.y += amountScrolledUp
self.collectionView.contentOffset.y -= firstItemAfterOriginalGapOffsetFromTop
}
snapshotView.removeFromSuperview()
}
}
} }
extension TimelineViewController { extension TimelineViewController {
@ -222,6 +418,7 @@ 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
@ -234,6 +431,8 @@ 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):
@ -250,12 +449,14 @@ 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 .loadingIndicator: case .gap:
hasher.combine(1) hasher.combine(1)
case .confirmLoadMore: case .loadingIndicator:
hasher.combine(2) hasher.combine(2)
case .publicTimelineDescription: case .confirmLoadMore:
hasher.combine(3) hasher.combine(3)
case .publicTimelineDescription:
hasher.combine(4)
} }
} }
@ -270,7 +471,7 @@ extension TimelineViewController {
var isSelectable: Bool { var isSelectable: Bool {
switch self { switch self {
case .publicTimelineDescription, .status(id: _, state: _): case .publicTimelineDescription, .gap, .status(id: _, state: _):
return true return true
default: default:
return false return false
@ -286,50 +487,48 @@ 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)
if !statuses.isEmpty { await withCheckedContinuation { continuation in
newer = .after(id: statuses.first!.id, count: nil)
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(returning: statuses.map(\.id)) continuation.resume()
} }
} }
return statuses.map(\.id)
} }
func loadNewer() async throws -> [TimelineItem] { func loadNewer() async throws -> [TimelineItem] {
guard let newer else { let statusesSection = dataSource.snapshot().indexOfSection(.statuses)!
guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: 0, section: statusesSection)) else {
throw Error.noNewer 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 Error.allCaughtUp throw TimelineViewController.Error.allCaughtUp
} }
self.newer = .after(id: statuses.first!.id, count: nil) await withCheckedContinuation { continuation in
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) { mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume(returning: statuses.map(\.id)) continuation.resume()
} }
} }
return statuses.map(\.id)
} }
func loadOlder() async throws -> [TimelineItem] { func loadOlder() async throws -> [TimelineItem] {
guard let older else { let snapshot = dataSource.snapshot()
throw Error.noOlder let statusesSection = snapshot.indexOfSection(.statuses)!
guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: snapshot.numberOfItems(inSection: .statuses) - 1, section: statusesSection)) else {
throw Error.noNewer
} }
let older = RequestRange.before(id: id, count: nil)
let request = Client.getStatuses(timeline: timeline, range: older) let request = Client.getStatuses(timeline: timeline, range: older)
let (statuses, _) = try await mastodonController.run(request) let (statuses, _) = try await mastodonController.run(request)
@ -338,13 +537,149 @@ extension TimelineViewController {
return [] return []
} }
self.older = .before(id: statuses.last!.id, count: nil) await withCheckedContinuation { continuation in
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) { mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume(returning: statuses.map(\.id)) continuation.resume()
} }
} }
return statuses.map(\.id)
}
func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] {
guard let gapIndexPath = dataSource.indexPath(for: .gap) else {
throw Error.noGap
}
let statusItemsCount = collectionView.numberOfItems(inSection: gapIndexPath.section)
let range: RequestRange
switch direction {
case .above:
guard gapIndexPath.row > 0,
case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row - 1, section: gapIndexPath.section)) else {
// not really the right error but w/e
throw Error.noGap
}
range = .before(id: id, count: nil)
case .below:
guard gapIndexPath.row < statusItemsCount - 1,
case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row + 1, section: gapIndexPath.section)) else {
throw Error.noGap
}
range = .after(id: id, count: nil)
}
let request = Client.getStatuses(timeline: timeline, range: range)
let (statuses, _) = try await mastodonController.run(request)
guard !statuses.isEmpty else {
return []
}
// NOTE: closing the gap (if necessary) happens in handleFillGap
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume()
}
}
return statuses.map(\.id)
}
func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async {
// TODO: better title, involving direction?
let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
Task {
await self?.controller.fillGap(in: direction)
}
}
self.showToast(configuration: config, animated: true)
}
func handleFillGap(_ timelineItems: [String], direction: TimelineGapDirection) async {
var snapshot = dataSource.snapshot()
let addedItems: Bool
let statusItems = snapshot.itemIdentifiers(inSection: .statuses)
let gapIndex = statusItems.firstIndex(of: .gap)!
switch direction {
case .above:
// dropFirst to remove .gap item
let afterGap = statusItems[gapIndex...].dropFirst().prefix(20)
precondition(!afterGap.contains(.gap))
// if there is any overlap, the first overlapping item will be the first item below the gap
var indexOfFirstTimelineItemExistingBelowGap: Int?
if case .status(id: let id, state: _) = afterGap.first {
indexOfFirstTimelineItemExistingBelowGap = timelineItems.firstIndex(of: id)
}
// the end index of the range of timelineItems that don't yet exist in the data source
let endIndex = indexOfFirstTimelineItemExistingBelowGap ?? timelineItems.endIndex
let toInsert = timelineItems[..<endIndex].map { Item.status(id: $0, state: .unknown) }
if toInsert.isEmpty {
addedItems = false
} else {
snapshot.insertItems(toInsert, beforeItem: .gap)
addedItems = true
}
// if there's any overlap between the items we loaded to insert above the gap
// and the items that already exist below the gap, we've completely filled the gap
if indexOfFirstTimelineItemExistingBelowGap != nil {
snapshot.deleteItems([.gap])
}
await apply(snapshot, animatingDifferences: !addedItems)
case .below:
let beforeGap = statusItems[..<gapIndex].suffix(20)
precondition(!beforeGap.contains(.gap))
// if there's any overlap, last overlapping item will be the last item below the gap
var indexOfLastTimelineItemExistingAboveGap: Int?
if case .status(id: let id, state: _) = beforeGap.last {
indexOfLastTimelineItemExistingAboveGap = timelineItems.lastIndex(of: id)
}
// the start index of the reange of timeline items that don't yet exist in the data source
let startIndex: Int
if let indexOfLastTimelineItemExistingAboveGap {
// index(after:) because the beginning of the range is inclusive, but we don't want the item at indexOfLastTimelineItemExistingAboveGap
startIndex = timelineItems.index(after: indexOfLastTimelineItemExistingAboveGap)
} else {
startIndex = timelineItems.startIndex
}
let toInsert = timelineItems[startIndex...].map { Item.status(id: $0, state: .unknown) }
if toInsert.isEmpty {
addedItems = false
} else {
snapshot.insertItems(toInsert, afterItem: .gap)
addedItems = true
}
// if there's any overlap between the items we loaded to insert below the gap
// and the items that already exist above the gap, we've completely filled the gap
if indexOfLastTimelineItemExistingAboveGap != nil {
snapshot.deleteItems([.gap])
}
if addedItems {
let firstItemAfterOriginalGap = statusItems[gapIndex + 1]
applySnapshot(snapshot, maintainingBottomRelativeScrollPositionOf: firstItemAfterOriginalGap)
} else {
dataSource.apply(snapshot, animatingDifferences: true) {}
}
}
// if we didn't add any items, that implies the gap was removed, and we want to to make clear what's happening
if !addedItems {
var config = ToastConfiguration(title: "There's nothing in between!")
config.dismissAutomaticallyAfter = 2
showToast(configuration: config, animated: true)
}
} }
enum Error: TimelineLikeCollectionViewError { enum Error: TimelineLikeCollectionViewError {
@ -352,6 +687,7 @@ extension TimelineViewController {
case noNewer case noNewer
case noOlder case noOlder
case allCaughtUp case allCaughtUp
case noGap
} }
} }
@ -384,6 +720,13 @@ 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")
} }

View File

@ -46,4 +46,22 @@ 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)
}
} }

View File

@ -357,6 +357,7 @@ 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()

View File

@ -66,8 +66,22 @@ extension MenuActionProvider {
] ]
var suppressSection: [UIMenuElement] = [] var suppressSection: [UIMenuElement] = []
if accountID != loggedInAccountID { if let ownAccount = mastodonController.account,
accountID != ownAccount.id {
actionsSection.append(relationshipAction(accountID: accountID, mastodonController: mastodonController, builder: { [unowned self] in self.followAction(for: $0, mastodonController: $1) })) actionsSection.append(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) }))
} }
@ -124,7 +138,7 @@ extension MenuActionProvider {
] ]
} }
func actionsForStatus(_ status: StatusMO, sourceView: UIView?, includeReply: Bool = true) -> [UIMenuElement] { func actionsForStatus(_ status: StatusMO, sourceView: UIView?, includeStatusButtonActions: Bool = true) -> [UIMenuElement] {
guard let mastodonController = mastodonController else { return [] } guard let mastodonController = mastodonController else { return [] }
guard let accountID = mastodonController.accountInfo?.id else { guard let accountID = mastodonController.accountInfo?.id else {
@ -155,7 +169,8 @@ extension MenuActionProvider {
}), }),
] ]
if #available(iOS 16.0, *) { if #available(iOS 16.0, *),
includeStatusButtonActions {
let favorited = status.favourited 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")!
@ -186,7 +201,7 @@ extension MenuActionProvider {
var actionsSection: [UIAction] = [] var actionsSection: [UIAction] = []
if includeReply { if includeStatusButtonActions {
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)
@ -264,8 +279,9 @@ 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: .medium, children: toggleableSection + actionsSection), UIMenu(options: .displayInline, preferredElementSize: toggleableAndActions.count == 1 ? .large : .medium, children: toggleableAndActions),
UIMenu(options: .displayInline, children: shareSection), UIMenu(options: .displayInline, children: shareSection),
] ]
} else { } else {

View File

@ -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(_, _) = await controller.state, if case .loadingInitial(_, _) = 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(_, _) = await controller.state, if case .loadingInitial(_, _) = controller.state,
let refreshControl = collectionView.refreshControl, let refreshControl = collectionView.refreshControl,
refreshControl.isRefreshing { refreshControl.isRefreshing {
refreshControl.endRefreshing() refreshControl.endRefreshing()
@ -179,6 +179,17 @@ 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 {
@ -206,7 +217,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(_, _) = await controller.state { if case .loadingOlder(_, _) = controller.state {
cell.isLoading = true cell.isLoading = true
} else { } else {
cell.isLoading = false cell.isLoading = false

View File

@ -22,6 +22,18 @@ extension NSUserActivity {
} }
} }
var isStateRestorationActivity: Bool {
get {
(userInfo?["isStateRestorationActivity"] as? Bool) ?? false
}
set {
if userInfo == nil {
userInfo = [:]
}
userInfo!["isStateRestorationActivity"] = newValue
}
}
convenience init(type: UserActivityType) { convenience init(type: UserActivityType) {
self.init(activityType: type.rawValue) self.init(activityType: type.rawValue)
} }

View File

@ -16,9 +16,11 @@ 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 canLoadOlder() async -> Bool func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem]
func handleAddLoadingIndicator() async func handleAddLoadingIndicator() async
func handleRemoveLoadingIndicator() async func handleRemoveLoadingIndicator() async
@ -28,13 +30,16 @@ 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")
actor TimelineLikeController<Item> { @MainActor
class TimelineLikeController<Item> {
unowned var delegate: any TimelineLikeControllerDelegate<Item> private unowned var delegate: any TimelineLikeControllerDelegate<Item>
private(set) var state = State.notLoadedInitial { private(set) var state = State.notLoadedInitial {
willSet { willSet {
@ -74,6 +79,16 @@ actor TimelineLikeController<Item> {
} }
} }
/// Used to indicate to the controller that the initial set of posts have been restored externally.
func restoreInitial(doRestore: () -> Void) {
guard state == .notLoadedInitial else {
return
}
state = .restoringInitial
doRestore()
state = .idle
}
func loadNewer() async { func loadNewer() async {
guard state == .idle else { guard state == .idle else {
return return
@ -126,6 +141,27 @@ actor TimelineLikeController<Item> {
} }
} }
func fillGap(in direction: TimelineGapDirection) async {
guard state == .idle else {
return
}
let token = LoadAttemptToken()
state = .loadingGap(token, direction)
do {
let items = try await delegate.loadGap(in: direction)
guard case .loadingGap(token, direction) = state else {
return
}
await emit(event: .fillGap(items, direction, token))
state = .idle
} catch is CancellationError {
return
} catch {
await emit(event: .loadGapError(error, direction, token))
state = .idle
}
}
private func transition(to newState: State) { private func transition(to newState: State) {
self.state = newState self.state = newState
} }
@ -152,15 +188,21 @@ actor 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 {
@ -168,12 +210,16 @@ actor 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))"
} }
} }
@ -181,24 +227,28 @@ actor TimelineLikeController<Item> {
switch self { switch self {
case .notLoadedInitial: case .notLoadedInitial:
switch to { switch to {
case .loadingInitial(_, _): case .restoringInitial, .loadingInitial(_, _):
return true return true
default: default:
return false return false
} }
case .idle: case .idle:
switch to { switch to {
case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _): case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _):
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
} }
} }
@ -239,6 +289,13 @@ actor 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
}
} }
} }
} }
@ -252,6 +309,8 @@ actor 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 {
@ -271,6 +330,10 @@ actor 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))"
} }
} }
} }
@ -310,3 +373,10 @@ actor TimelineLikeController<Item> {
} }
} }
enum TimelineGapDirection {
/// Fill in below the gap. I.e., statuses that are immediately newer than the status below the gap.
case below
/// Fill in above the gap. I.e., statuses that are immediately older than the status above the gap.
case above
}

View File

@ -178,13 +178,13 @@ extension TuskerNavigationDelegate {
} }
func showFollowedByList(accountIDs: [String]) { func showFollowedByList(accountIDs: [String]) {
let vc = AccountListTableViewController(accountIDs: accountIDs, mastodonController: apiController) let vc = AccountListViewController(accountIDs: accountIDs, mastodonController: apiController)
vc.title = NSLocalizedString("Followed By", comment: "followed by accounts list title") vc.title = NSLocalizedString("Followed By", comment: "followed by accounts list title")
show(vc, sender: self) show(vc, sender: self)
} }
func statusActionAccountList(action: StatusActionAccountListTableViewController.ActionType, statusID: String, statusState state: StatusState, accountIDs: [String]?) -> StatusActionAccountListTableViewController { func statusActionAccountList(action: StatusActionAccountListViewController.ActionType, statusID: String, statusState state: StatusState, accountIDs: [String]?) -> StatusActionAccountListViewController {
return StatusActionAccountListTableViewController(actionType: action, statusID: statusID, statusState: state, accountIDs: accountIDs, mastodonController: apiController) return StatusActionAccountListViewController(actionType: action, statusID: statusID, statusState: state, accountIDs: accountIDs, mastodonController: apiController)
} }
} }

View File

@ -0,0 +1,148 @@
//
// AccountCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 11/22/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import SwiftSoup
class AccountCollectionViewCell: UICollectionViewListCell {
private lazy var hStack = UIStackView(arrangedSubviews: [
avatarImageView,
vStack,
]).configure {
$0.axis = .horizontal
$0.spacing = 8
$0.alignment = .leading
}
private let avatarImageView = CachedImageView(cache: .avatars).configure {
$0.layer.masksToBounds = true
NSLayoutConstraint.activate([
$0.widthAnchor.constraint(equalToConstant: 50),
$0.heightAnchor.constraint(equalToConstant: 50),
])
}
private lazy var vStack = UIStackView(arrangedSubviews: [
displayNameLabel,
usernameLabel,
noteLabel,
]).configure {
$0.axis = .vertical
$0.spacing = 4
$0.alignment = .leading
}
private let displayNameLabel = EmojiLabel().configure {
$0.font = .preferredFont(forTextStyle: .body)
$0.adjustsFontSizeToFitWidth = true
$0.adjustsFontForContentSizeCategory = true
}
private let usernameLabel = UILabel().configure {
$0.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .light))
$0.textColor = .secondaryLabel
$0.adjustsFontSizeToFitWidth = true
$0.adjustsFontForContentSizeCategory = true
}
private let noteLabel = EmojiLabel().configure {
$0.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15))
$0.numberOfLines = 2
}
weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
var mastodonController: MastodonController! { delegate?.apiController }
private var accountID: String?
private var isGrayscale = false
override init(frame: CGRect) {
super.init(frame: .zero)
hStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(hStack)
NSLayoutConstraint.activate([
hStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8),
hStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
hStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
hStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
])
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Accessibility
override var isAccessibilityElement: Bool {
get { true }
set {}
}
override var accessibilityAttributedLabel: NSAttributedString? {
get {
guard let accountID,
let account = mastodonController.persistentContainer.account(for: accountID) else {
return nil
}
var str = AttributedString(account.displayOrUserName)
str += ", @"
str += AttributedString(account.acct)
return NSAttributedString(str)
}
set {}
}
override func accessibilityActivate() -> Bool {
guard let accountID else {
return false
}
delegate?.selected(account: accountID)
return true
}
// MARK: Configure UI
func updateUI(accountID: String) {
guard let account = mastodonController.persistentContainer.account(for: accountID) else {
fatalError()
}
self.accountID = accountID
avatarImageView.update(for: account.avatar)
usernameLabel.text = "@\(account.acct)"
updateUIForPreferences(account: account)
}
private func updateUIForPreferences(account: AccountMO) {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 50
isGrayscale = Preferences.shared.grayscaleImages
if Preferences.shared.hideCustomEmojiInUsernames {
displayNameLabel.text = account.displayNameWithoutCustomEmoji
} else {
displayNameLabel.text = account.displayOrUserName
displayNameLabel.setEmojis(account.emojis, identifier: account.id)
}
let doc = try! SwiftSoup.parseBodyFragment(account.note)
noteLabel.text = try! doc.text()
noteLabel.setEmojis(account.emojis, identifier: account.id)
}
@objc private func preferencesChanged() {
if let accountID,
let account = mastodonController?.persistentContainer.account(for: accountID) {
updateUIForPreferences(account: account)
}
}
}

View File

@ -56,6 +56,7 @@ 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
} }
} }

View File

@ -28,7 +28,7 @@ class ConfirmLoadMoreCollectionViewCell: UICollectionViewCell {
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
backgroundColor = .secondarySystemBackground backgroundColor = .systemGroupedBackground
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?"

View File

@ -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: StatusActionAccountListTableViewController.ActionType let action: StatusActionAccountListViewController.ActionType
switch notifications.first!.kind { switch notifications.first!.kind {
case .favourite: case .favourite:
action = .favorite action = .favorite
@ -228,6 +228,7 @@ 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)
} }
} }
@ -235,9 +236,12 @@ 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: StatusActionAccountListTableViewController.ActionType let action: StatusActionAccountListViewController.ActionType
switch notifications.first!.kind { switch notifications.first!.kind {
case .favourite: case .favourite:
action = .favorite action = .favorite
@ -246,7 +250,9 @@ extension ActionNotificationGroupTableViewCell: MenuPreviewProvider {
default: default:
fatalError() fatalError()
} }
return self.delegate?.statusActionAccountList(action: action, statusID: self.statusID, statusState: .unknown, accountIDs: accountIDs) let vc = delegate.statusActionAccountList(action: action, statusID: self.statusID, statusState: .unknown, accountIDs: accountIDs)
vc.showInacurateCountWarning = false
return vc
}, actions: { }, actions: {
return [] return []
}) })

View File

@ -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 AccountListTableViewController(accountIDs: accountIDs, mastodonController: mastodonController) return AccountListViewController(accountIDs: accountIDs, mastodonController: mastodonController)
} }
}, actions: { }, actions: {
if accountIDs.count == 1 { if accountIDs.count == 1 {

View File

@ -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"

View File

@ -7,13 +7,15 @@
// //
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, ContentTextView)] = [] private var fieldViews: [(EmojiLabel, ProfileFieldValueView)] = []
private var fieldConstraints: [NSLayoutConstraint] = [] private var fieldConstraints: [NSLayoutConstraint] = []
private var isUsingSingleColumn: Bool = false private var isUsingSingleColumn: Bool = false
@ -80,16 +82,11 @@ 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 valueTextView = ContentTextView() let valueView = ProfileFieldValueView(field: field, account: account)
valueTextView.isSelectable = false valueView.navigationDelegate = delegate
valueTextView.defaultFont = .preferredFont(forTextStyle: .body) valueView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
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, valueTextView)) fieldViews.append((nameLabel, valueView))
} }
configureFields() configureFields()
@ -121,7 +118,7 @@ class ProfileFieldsView: UIView {
} }
name.textAlignment = .natural name.textAlignment = .natural
stack.addArrangedSubview(name) stack.addArrangedSubview(name)
value.textAlignment = .natural value.setTextAlignment(.natural)
stack.addArrangedSubview(value) stack.addArrangedSubview(value)
} }
} else { } else {
@ -137,7 +134,7 @@ class ProfileFieldsView: UIView {
name.textAlignment = .right name.textAlignment = .right
name.translatesAutoresizingMaskIntoConstraints = false name.translatesAutoresizingMaskIntoConstraints = false
value.textAlignment = .left value.setTextAlignment(.left)
value.translatesAutoresizingMaskIntoConstraints = false value.translatesAutoresizingMaskIntoConstraints = false
let fieldContainer = UIView() let fieldContainer = UIView()
@ -165,3 +162,159 @@ class ProfileFieldsView: UIView {
} }
} }
private class ProfileFieldValueView: UIView {
weak var navigationDelegate: TuskerNavigationDelegate? {
didSet {
textView.navigationDelegate = navigationDelegate
}
}
private let account: AccountMO
private let field: Account.Field
private let textView = ContentTextView()
private var iconView: UIView?
init(field: Account.Field, account: AccountMO) {
self.account = account
self.field = field
super.init(frame: .zero)
textView.isSelectable = false
textView.defaultFont = .preferredFont(forTextStyle: .body)
textView.adjustsFontForContentSizeCategory = true
textView.setTextFromHtml(field.value)
textView.setEmojis(account.emojis, identifier: account.id)
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textView.translatesAutoresizingMaskIntoConstraints = false
addSubview(textView)
let textViewTrailingConstraint: NSLayoutConstraint
if field.verifiedAt != nil {
var config = UIButton.Configuration.plain()
config.image = UIImage(systemName: "checkmark")
config.baseForegroundColor = .systemGreen
let icon = UIButton(configuration: config)
self.iconView = icon
icon.translatesAutoresizingMaskIntoConstraints = false
icon.setContentHuggingPriority(.defaultHigh, for: .horizontal)
icon.addTarget(self, action: #selector(verifiedIconTapped), for: .touchUpInside)
icon.isPointerInteractionEnabled = true
icon.accessibilityLabel = "Verified link"
addSubview(icon)
textViewTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor, constant: -4)
NSLayoutConstraint.activate([
icon.centerYAnchor.constraint(equalTo: centerYAnchor),
icon.trailingAnchor.constraint(equalTo: trailingAnchor),
])
} else {
textViewTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
}
NSLayoutConstraint.activate([
textView.leadingAnchor.constraint(equalTo: leadingAnchor),
textViewTrailingConstraint,
textView.topAnchor.constraint(equalTo: topAnchor),
textView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setTextAlignment(_ alignment: NSTextAlignment) {
textView.textAlignment = alignment
}
@objc private func verifiedIconTapped() {
guard let navigationDelegate else {
return
}
let view = ProfileFieldVerificationView(
acct: account.acct,
verifiedAt: field.verifiedAt!,
linkText: textView.text,
navigationDelegate: navigationDelegate
)
let host = UIHostingController(rootView: view)
let toPresent: UIViewController
if traitCollection.horizontalSizeClass == .compact || traitCollection.verticalSizeClass == .compact {
toPresent = UINavigationController(rootViewController: host)
toPresent.modalPresentationStyle = .pageSheet
let sheetPresentationController = toPresent.sheetPresentationController!
sheetPresentationController.detents = [
.medium()
]
} else {
host.modalPresentationStyle = .popover
let popoverPresentationController = host.popoverPresentationController!
popoverPresentationController.sourceView = iconView
host.preferredContentSize = host.sizeThatFits(in: CGSize(width: 400, height: CGFloat.infinity))
toPresent = host
}
navigationDelegate.present(toPresent, animated: true)
}
}
private struct ProfileFieldVerificationView: View {
let acct: String
let verifiedAt: Date
let linkText: String
let navigationDelegate: TuskerNavigationDelegate
var body: some View {
VStack(alignment: .leading, spacing: 8) {
firstLine
secondLine
}
.padding()
.navigationTitle(Text("Verified Link"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
navigationDelegate.dismiss(animated: true)
}
}
}
.environment(\.openURL, OpenURLAction(handler: { url in
// dismiss the sheet/popover first
navigationDelegate.dismiss(animated: true) {
navigationDelegate.selected(url: url)
}
return .handled
}))
}
private var firstLine: Text {
var attrStr: AttributedString = "This link has been verified by your instance, "
var instance = AttributedString(navigationDelegate.apiController!.instanceURL.host!)
instance.font = .body.bold()
attrStr += instance
attrStr += "."
return Text(attrStr)
}
private var secondLine: Text {
var attrStr: AttributedString = "The page at "
var linkStr: AttributedString
if linkText.count > 43 {
linkStr = AttributedString(linkText.prefix(40) + "")
} else {
linkStr = AttributedString(linkText)
}
linkStr.link = URL(string: linkText)
attrStr += linkStr
attrStr += " was confirmed to link back to "
var acctStr = AttributedString("@\(acct)")
acctStr.font = .body.bold()
attrStr += acctStr
attrStr += AttributedString(" as of \(verifiedAt.formatted(date: .abbreviated, time: .shortened)).")
return Text(attrStr)
}
}

View File

@ -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, includeReply: false) ?? []) moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, sourceView: moreButton, includeStatusButtonActions: false) ?? [])
pollView.isHidden = status.poll == nil pollView.isHidden = status.poll == nil
pollView.mastodonController = mastodonController pollView.mastodonController = mastodonController

View File

@ -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, includeReply: false) ?? []) moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, sourceView: moreButton, includeStatusButtonActions: false) ?? [])
contentContainer.pollView.isHidden = status.poll == nil contentContainer.pollView.isHidden = status.poll == nil
contentContainer.pollView.mastodonController = mastodonController contentContainer.pollView.mastodonController = mastodonController

View File

@ -16,10 +16,20 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
// MARK: Subviews // MARK: Subviews
private lazy var reblogLabel = EmojiLabel().configure { private lazy var rebloggerLabel = 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
@ -265,12 +275,12 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
for subview in [reblogLabel, mainContainer, actionsContainer] { for subview in [reblogHStack, mainContainer, actionsContainer] {
subview.translatesAutoresizingMaskIntoConstraints = false subview.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(subview) contentView.addSubview(subview)
} }
mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: reblogLabel.bottomAnchor, constant: 4) mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: reblogHStack.bottomAnchor, constant: 4)
mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8) 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)
@ -281,9 +291,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
reblogLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4), reblogHStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4),
reblogLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), rebloggerLabel.leadingAnchor.constraint(equalTo: contentVStack.leadingAnchor),
reblogLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), reblogHStack.trailingAnchor.constraint(lessThanOrEqualTo: 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),
@ -399,7 +409,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
reblogLabel.isHidden = false reblogHStack.isHidden = false
mainContainerTopToReblogLabelConstraint.isActive = true mainContainerTopToReblogLabelConstraint.isActive = true
mainContainerTopToSelfConstraint.isActive = false mainContainerTopToSelfConstraint.isActive = false
updateRebloggerLabel(reblogger: status.account) updateRebloggerLabel(reblogger: status.account)
@ -408,7 +418,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
} else { } else {
reblogStatusID = nil reblogStatusID = nil
rebloggerID = nil rebloggerID = nil
reblogLabel.isHidden = true reblogHStack.isHidden = true
mainContainerTopToReblogLabelConstraint.isActive = false mainContainerTopToReblogLabelConstraint.isActive = false
mainContainerTopToSelfConstraint.isActive = true mainContainerTopToSelfConstraint.isActive = true
} }
@ -492,11 +502,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
private func updateRebloggerLabel(reblogger: AccountMO) { private func updateRebloggerLabel(reblogger: AccountMO) {
if Preferences.shared.hideCustomEmojiInUsernames { if Preferences.shared.hideCustomEmojiInUsernames {
reblogLabel.text = "Reblogged by \(reblogger.displayNameWithoutCustomEmoji)" rebloggerLabel.text = "\(reblogger.displayNameWithoutCustomEmoji) reblogged"
reblogLabel.removeEmojis() rebloggerLabel.removeEmojis()
} else { } else {
reblogLabel.text = "Reblogged by \(reblogger.displayOrUserName)" rebloggerLabel.text = "\(reblogger.displayOrUserName) reblogged"
reblogLabel.setEmojis(reblogger.emojis, identifier: reblogger.id) rebloggerLabel.setEmojis(reblogger.emojis, identifier: reblogger.id)
} }
} }

View File

@ -11,7 +11,7 @@ import Pachyderm
struct ToastConfiguration { struct ToastConfiguration {
var systemImageName: String? var systemImageName: String?
var titleFont: UIFont = .boldSystemFont(ofSize: 14) var titleFont: UIFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .boldSystemFont(ofSize: 14))
var title: String var title: String
var subtitle: String? var subtitle: String?
var actionTitle: String? var actionTitle: String?

View File

@ -68,13 +68,15 @@ 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 = .systemFont(ofSize: 14) subtitleLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14))
subtitleLabel.adjustsFontForContentSizeCategory = true
let vStack = UIStackView(arrangedSubviews: [ let vStack = UIStackView(arrangedSubviews: [
titleLabel, titleLabel,
subtitleLabel subtitleLabel
@ -89,7 +91,8 @@ 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 = .boldSystemFont(ofSize: 16) actionLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .boldSystemFont(ofSize: 16))
actionLabel.adjustsFontForContentSizeCategory = true
actionLabel.textColor = .white actionLabel.textColor = .white
stack.addArrangedSubview(actionLabel) stack.addArrangedSubview(actionLabel)
} }

View File

@ -24,7 +24,7 @@ class VisualEffectImageButton: UIControl {
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
let blur = UIBlurEffect(style: .prominent) let blur = UIBlurEffect(style: .systemThickMaterial)
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)