Compare commits

...

6 Commits

Author SHA1 Message Date
Shadowfacts d05275020f Tweak timeline status cell spacing 2022-10-29 21:18:01 -04:00
Shadowfacts c420c236d9 Whoops 2022-10-29 21:06:27 -04:00
Shadowfacts d5433e9b91 Fix crash when opening profile view controller with uncached account
E.g., by tapping a mention in a status
2022-10-29 18:55:13 -04:00
Shadowfacts cbbe9ec11f Fix crash in profile due to accessing data source before it exists
This could happen if an account is updated in the background while a
profile is on screen and the user has not visited all of the tabs.
2022-10-29 18:40:41 -04:00
Shadowfacts 0e06d47687 Fix status collapse changes not animating on profiles 2022-10-29 18:27:24 -04:00
Shadowfacts c907b7257a Bump build number and update changelog 2022-10-29 18:27:12 -04:00
9 changed files with 96 additions and 41 deletions

View File

@ -1,5 +1,23 @@
# Changelog # Changelog
## 2022.1 (41)
Features/Improvements:
- Rewrite profile screens to use new timeline implementation
- Disable Infinite Scrolling preference (Preferences -> Digital Wellness) now applies to profiles
- Improve behavior when switching tabs on profiles
- Improve pointer interaction on timeline status cells
Bugfixes:
- Fix crash when loading images in certain circumstances
- Fix gallery dismissal leaving status bar hidden and breaking future gallery dismisses
- Fix timeline scroll position changing after dismissing gallery
- Fix images flickering when switching back to the Home tab
- Fix crash reporter being dismissed when sending email is cancelled
- Fix crash when long pressing Send Report button in crash reporter on iPad
- Fix Live Text controls not hiding when other gallery controls are hidden
- Fix replies appearing multiple times in Drafts list
- Fix crash when displaying blur hash images on Pleroma
## 2022.1 (40) ## 2022.1 (40)
Bugfixes: Bugfixes:
- Fix selecting reblogged statuses in the timeline - Fix selecting reblogged statuses in the timeline

View File

@ -2217,7 +2217,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 = 40; CURRENT_PROJECT_VERSION = 41;
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;
@ -2246,7 +2246,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 = 40; CURRENT_PROJECT_VERSION = 41;
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;
@ -2356,7 +2356,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 = 40; CURRENT_PROJECT_VERSION = 41;
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;
@ -2383,7 +2383,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 = 40; CURRENT_PROJECT_VERSION = 41;
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

@ -147,19 +147,20 @@ class MastodonCachePersistentStore: NSPersistentContainer {
} }
@discardableResult @discardableResult
private func upsert(account: Account) -> AccountMO { private func upsert(account: Account, in context: NSManagedObjectContext) -> AccountMO {
if let accountMO = self.account(for: account.id, in: self.backgroundContext) { if let accountMO = self.account(for: account.id, in: context) {
accountMO.updateFrom(apiAccount: account, container: self) accountMO.updateFrom(apiAccount: account, container: self)
return accountMO return accountMO
} else { } else {
return AccountMO(apiAccount: account, container: self, context: self.backgroundContext) return AccountMO(apiAccount: account, container: self, context: context)
} }
} }
func addOrUpdate(account: Account, completion: ((AccountMO) -> Void)? = nil) { func addOrUpdate(account: Account, in context: NSManagedObjectContext? = nil, completion: ((AccountMO) -> Void)? = nil) {
backgroundContext.perform { let context = context ?? backgroundContext
let accountMO = self.upsert(account: account) context.perform {
self.save(context: self.backgroundContext) let accountMO = self.upsert(account: account, in: context)
self.save(context: context)
completion?(accountMO) completion?(accountMO)
self.accountSubject.send(account.id) self.accountSubject.send(account.id)
} }
@ -199,7 +200,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
func addAll(accounts: [Account], completion: (() -> Void)? = nil) { func addAll(accounts: [Account], completion: (() -> Void)? = nil) {
backgroundContext.perform { backgroundContext.perform {
accounts.forEach { self.upsert(account: $0) } accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
self.save(context: self.backgroundContext) self.save(context: self.backgroundContext)
completion?() completion?()
accounts.forEach { self.accountSubject.send($0.id) } accounts.forEach { self.accountSubject.send($0.id) }
@ -213,7 +214,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
// since the status has the same account as the notification // since the status has the same account as the notification
let accounts = notifications.filter { $0.kind != .mention }.map { $0.account } let accounts = notifications.filter { $0.kind != .mention }.map { $0.account }
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) } statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
accounts.forEach { self.upsert(account: $0) } accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
self.save(context: self.backgroundContext) self.save(context: self.backgroundContext)
completion?() completion?()
statuses.forEach { self.statusSubject.send($0.id) } statuses.forEach { self.statusSubject.send($0.id) }
@ -227,7 +228,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
var updatedStatuses = [String]() var updatedStatuses = [String]()
block(self.backgroundContext, { (accounts) in block(self.backgroundContext, { (accounts) in
accounts.forEach { self.upsert(account: $0) } accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
updatedAccounts.append(contentsOf: accounts.map { $0.id }) updatedAccounts.append(contentsOf: accounts.map { $0.id })
}, { (statuses) in }, { (statuses) in
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) } statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }

View File

@ -2,6 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<array> <array>
<string>counter\.social</string>
<string>gab\..+</string> <string>gab\..+</string>
</array> </array>
</plist> </plist>

View File

@ -18,6 +18,12 @@ fileprivate let instanceCell = "instanceCell"
class InstanceSelectorTableViewController: UITableViewController { class InstanceSelectorTableViewController: UITableViewController {
static var blocks: [NSRegularExpression] = {
guard let path = Bundle.main.path(forResource: "DomainBlocks", ofType: "plist"),
let array = NSArray(contentsOfFile: path) as? [String] else { return [] }
return array.compactMap { try? NSRegularExpression(pattern: $0, options: .caseInsensitive) }
}()
weak var delegate: InstanceSelectorTableViewControllerDelegate? weak var delegate: InstanceSelectorTableViewControllerDelegate?
var dataSource: DataSource! var dataSource: DataSource!
@ -100,7 +106,7 @@ class InstanceSelectorTableViewController: UITableViewController {
loadRecommendedInstances() loadRecommendedInstances()
} }
private func parseURLComponents(input: String) -> URLComponents { private func parseURLComponents(input: String) -> URLComponents? {
// we can't just use the URLComponents(string:) initializer, because when given just a domain (w/o protocol), it interprets it as the path // we can't just use the URLComponents(string:) initializer, because when given just a domain (w/o protocol), it interprets it as the path
var input = input var input = input
var components = URLComponents() var components = URLComponents()
@ -125,13 +131,24 @@ class InstanceSelectorTableViewController: UITableViewController {
components.port = Int(parts.last!) components.port = Int(parts.last!)
} }
components.host = input components.host = input
if Self.blocks.contains(where: { $0.numberOfMatches(in: input, range: NSRange(location: 0, length: input.utf16.count)) > 0 }) {
return nil
}
return components return components
} }
private func updateSpecificInstance(domain: String) { private func updateSpecificInstance(domain: String) {
activityIndicator.startAnimating() activityIndicator.startAnimating()
let components = parseURLComponents(input: domain) guard let components = parseURLComponents(input: domain) else {
var snapshot = dataSource.snapshot()
if snapshot.indexOfSection(.selected) != nil {
snapshot.deleteSections([.selected])
dataSource.apply(snapshot)
}
activityIndicator.stopAnimating()
return
}
let url = components.url! let url = components.url!
let client = Client(baseURL: url, session: .appDefault) let client = Client(baseURL: url, session: .appDefault)

View File

@ -17,12 +17,6 @@ protocol OnboardingViewControllerDelegate {
class OnboardingViewController: UINavigationController { class OnboardingViewController: UINavigationController {
static var blocks: [NSRegularExpression] = {
guard let path = Bundle.main.path(forResource: "DomainBlocks", ofType: "plist"),
let array = NSArray(contentsOfFile: path) as? [String] else { return [] }
return array.compactMap { try? NSRegularExpression(pattern: $0, options: .caseInsensitive) }
}()
var onboardingDelegate: OnboardingViewControllerDelegate? var onboardingDelegate: OnboardingViewControllerDelegate?
var instanceSelector = InstanceSelectorTableViewController() var instanceSelector = InstanceSelectorTableViewController()

View File

@ -29,9 +29,10 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
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?
private var state: State = .unloaded
init(accountID: String?, kind: Kind, owner: ProfileViewController) { init(accountID: String?, kind: Kind, owner: ProfileViewController) {
self.accountID = accountID self.accountID = accountID
self.kind = kind self.kind = kind
@ -41,16 +42,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
self.controller = TimelineLikeController(delegate: self) self.controller = TimelineLikeController(delegate: self)
mastodonController.persistentContainer.accountSubject
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.accountID }
.sink { [unowned self] id in
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([.header(id)])
dataSource.apply(snapshot, animatingDifferences: true)
}
.store(in: &cancellables)
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Profile")) addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Profile"))
} }
@ -97,7 +88,25 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
mastodonController.persistentContainer.accountSubject
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.accountID }
.sink { [unowned self] id in
switch state {
case .unloaded:
Task {
await load()
}
case .loading:
break
case .loaded, .addedHeader:
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([.header(id)])
dataSource.apply(snapshot, animatingDifferences: true)
}
}
.store(in: &cancellables)
} }
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> { private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
@ -160,20 +169,26 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
} }
private func load() async { private func load() async {
guard accountID != nil, guard isViewLoaded,
await controller.state == .notLoadedInitial, let accountID,
isViewLoaded else { case .unloaded = state,
mastodonController.persistentContainer.account(for: accountID) != nil else {
return return
} }
state = .loading
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.header, .pinned, .statuses]) snapshot.appendSections([.header, .pinned, .statuses])
snapshot.appendItems([.header(accountID)], toSection: .header) snapshot.appendItems([.header(accountID)], toSection: .header)
await apply(snapshot, animatingDifferences: false) await apply(snapshot, animatingDifferences: false)
print("added header item")
state = .addedHeader
await controller.loadInitial() await controller.loadInitial()
await tryLoadPinned() await tryLoadPinned()
state = .loaded
} }
private func tryLoadPinned() async { private func tryLoadPinned() async {
@ -223,6 +238,15 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
} }
extension ProfileStatusesViewController {
enum State {
case unloaded
case loading
case addedHeader
case loaded
}
}
extension ProfileStatusesViewController { extension ProfileStatusesViewController {
enum Kind { enum Kind {
case statuses, withReplies, onlyMedia case statuses, withReplies, onlyMedia
@ -436,7 +460,7 @@ extension ProfileStatusesViewController: StatusCollectionViewCellDelegate {
if let indexPath = collectionView.indexPath(for: cell) { if let indexPath = collectionView.indexPath(for: cell) {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!]) snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!])
dataSource.apply(snapshot, animatingDifferences: false, completion: completion) dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
} }
} }
} }

View File

@ -116,7 +116,7 @@ class ProfileViewController: UIPageViewController {
let req = Client.getAccount(id: accountID) let req = Client.getAccount(id: accountID)
let (account, _) = try await mastodonController.run(req) let (account, _) = try await mastodonController.run(req)
let mo = await withCheckedContinuation { continuation in let mo = await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addOrUpdate(account: account) { (mo) in mastodonController.persistentContainer.addOrUpdate(account: account, in: mastodonController.persistentContainer.viewContext) { (mo) in
continuation.resume(returning: mo) continuation.resume(returning: mo)
} }
} }

View File

@ -261,7 +261,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: reblogLabel.bottomAnchor, constant: 4) mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: reblogLabel.bottomAnchor, constant: 4)
mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8) mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8)
mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor) 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)
let metaIndicatorsBottomConstraint = metaIndicatorsView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -6) let metaIndicatorsBottomConstraint = metaIndicatorsView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -6)