From 43ee11b863cd61bcbc06e6e0dd255f33c4f96da7 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 1 Apr 2021 14:39:15 +0800 Subject: [PATCH 1/2] feat: [WIP] add profile scene --- .../CoreData.xcdatamodel/contents | 100 ++- CoreDataStack/CoreDataStack.swift | 2 +- CoreDataStack/Entity/Application.swift | 4 +- CoreDataStack/Entity/Attachment.swift | 2 +- CoreDataStack/Entity/Emoji.swift | 2 +- CoreDataStack/Entity/HomeTimelineIndex.swift | 8 +- CoreDataStack/Entity/MastodonUser.swift | 168 ++++- CoreDataStack/Entity/Mention.swift | 2 +- CoreDataStack/Entity/Poll.swift | 2 +- CoreDataStack/Entity/PrivateNote.swift | 56 ++ .../Entity/{Toot.swift => Status.swift} | 86 +-- CoreDataStack/Entity/Tag.swift | 2 +- Localization/app.json | 22 + Localization/ios-infoPlist.json | 4 +- Mastodon.xcodeproj/project.pbxproj | 209 +++++- .../xcshareddata/swiftpm/Package.resolved | 18 + Mastodon/Coordinator/SceneCoordinator.swift | 15 +- .../StatusFetchedResultsController.swift | 86 +++ Mastodon/Diffiable/Item/Item.swift | 44 +- .../Section/ComposeStatusSection.swift | 8 +- .../Diffiable/Section/StatusSection.swift | 85 +-- Mastodon/Extension/ActiveLabel.swift | 45 +- Mastodon/Extension/CGImage.swift | 154 ++++ .../CoreDataStack/MastodonUser.swift | 25 + .../{Toot.swift => Status.swift} | 4 +- Mastodon/Extension/UIImage.swift | 7 + .../Extension/UINavigationController.swift | 17 + Mastodon/Extension/UITabBarController.swift | 14 + Mastodon/Generated/Assets.swift | 1 + Mastodon/Generated/Strings.swift | 36 + Mastodon/Helper/MastodonField.swift | 48 ++ ...tent.swift => MastodonStatusContent.swift} | 51 +- Mastodon/Info.plist | 2 - ...Provider+StatusTableViewCellDelegate.swift | 29 +- ...der+UITableViewDataSourcePrefetching.swift | 14 +- .../StatusProvider+UITableViewDelegate.swift | 28 +- .../StatusProvider/StatusProvider.swift | 6 +- .../StatusProvider/StatusProviderFacade.swift | 109 ++- .../alert.yellow.colorset/Contents.json | 20 + Mastodon/Resources/en.lproj/InfoPlist.strings | 2 +- .../Resources/en.lproj/Localizable.strings | 14 + ...edToStatusContentCollectionViewCell.swift} | 6 +- .../Scene/Compose/ComposeViewController.swift | 2 +- ...meTimelineViewController+DebugAction.swift | 30 +- ...imelineViewController+StatusProvider.swift | 8 +- .../HomeTimelineViewController.swift | 9 +- .../HomeTimelineViewModel+Diffable.swift | 11 +- ...omeTimelineViewModel+LoadLatestState.swift | 24 +- ...omeTimelineViewModel+LoadMiddleState.swift | 16 +- ...omeTimelineViewModel+LoadOldestState.swift | 10 +- .../HomeTimeline/HomeTimelineViewModel.swift | 2 +- .../Scene/MainTab/MainTabBarController.swift | 16 +- .../MastodonConfirmEmailViewController.swift | 4 + .../MastodonPickServerViewController.swift | 4 + .../MastodonRegisterViewController.swift | 4 + .../MastodonResendEmailViewController.swift | 6 + .../MastodonServerRulesViewController.swift | 4 + .../Welcome/WelcomeViewController.swift | 4 + .../Profile/CachedProfileViewModel.swift | 17 + .../Header/ProfileHeaderViewController.swift | 142 ++++ .../Header/View/ProfileFieldView.swift | 100 +++ .../View/ProfileFriendshipActionButton.swift | 71 ++ .../Header/View/ProfileHeaderView.swift | 247 +++++++ .../ProfileStatusDashboardMeterView.swift | 79 +++ .../View/ProfileStatusDashboardView.swift | 103 +++ .../Scene/Profile/MeProfileViewModel.swift | 33 + .../Scene/Profile/ProfileViewController.swift | 658 +++++++++++++++++- Mastodon/Scene/Profile/ProfileViewModel.swift | 197 ++++++ .../Paging/ProfilePagingViewController.swift | 47 ++ .../Paging/ProfilePagingViewModel.swift | 68 ++ .../ProfileSegmentedViewController.swift | 38 + ...imelineViewController+StatusProvider.swift | 87 +++ .../Timeline/UserTimelineViewController.swift | 145 ++++ .../UserTimelineViewModel+Diffable.swift | 37 + .../UserTimelineViewModel+State.swift | 262 +++++++ .../Timeline/UserTimelineViewModel.swift | 127 ++++ ...imelineViewController+StatusProvider.swift | 12 +- .../PublicTimelineViewController.swift | 18 +- .../PublicTimelineViewModel+Diffable.swift | 30 +- ...licTimelineViewModel+LoadMiddleState.swift | 44 +- .../PublicTimelineViewModel+State.swift | 30 +- .../PublicTimelineViewModel.swift | 14 +- ...veStatusBarStyleNavigationController.swift | 16 + ...ntStatusBarStyleNavigationController.swift | 14 - .../View/Button/RoundedEdgesButton.swift | 2 +- .../Scene/Share/View/Content/StatusView.swift | 28 +- .../TableviewCell/StatusTableViewCell.swift | 10 + .../TimelineMiddleLoaderTableViewCell.swift | 2 +- .../APIService/APIService+Favorite.swift | 38 +- .../APIService/APIService+HomeTimeline.swift | 2 +- .../APIService+PublicTimeline.swift | 2 +- .../APIService/APIService+Reblog.swift | 36 +- .../APIService/APIService+Relationship.swift | 65 ++ .../APIService/APIService+UserTimeline.swift | 70 ++ Mastodon/Service/APIService/APIService.swift | 2 +- .../APIService+CoreData+MastodonUser.swift | 52 +- .../CoreData/APIService+CoreData+Status.swift | 104 +-- .../APIService+Persist+PersistCache.swift | 30 +- .../APIService+Persist+PersistMemo.swift | 26 +- .../Persist/APIService+Persist+Status.swift | 74 +- .../Service/StatusPrefetchingService.swift | 6 +- Mastodon/Service/ViedeoPlaybackService.swift | 4 +- Mastodon/ViewController.swift | 15 + .../API/Mastodon+API+Account+Friendship.swift | 69 ++ .../API/Mastodon+API+Account.swift | 79 +++ 105 files changed, 4450 insertions(+), 613 deletions(-) create mode 100644 CoreDataStack/Entity/PrivateNote.swift rename CoreDataStack/Entity/{Toot.swift => Status.swift} (75%) create mode 100644 Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift create mode 100644 Mastodon/Extension/CGImage.swift rename Mastodon/Extension/CoreDataStack/{Toot.swift => Status.swift} (95%) create mode 100644 Mastodon/Extension/UINavigationController.swift create mode 100644 Mastodon/Extension/UITabBarController.swift create mode 100644 Mastodon/Helper/MastodonField.swift rename Mastodon/Helper/{TootContent.swift => MastodonStatusContent.swift} (83%) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/alert.yellow.colorset/Contents.json rename Mastodon/Scene/Compose/CollectionViewCell/{ComposeRepliedToTootContentCollectionViewCell.swift => ComposeRepliedToStatusContentCollectionViewCell.swift} (62%) create mode 100644 Mastodon/Scene/Profile/CachedProfileViewModel.swift create mode 100644 Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift create mode 100644 Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift create mode 100644 Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift create mode 100644 Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift create mode 100644 Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift create mode 100644 Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift create mode 100644 Mastodon/Scene/Profile/MeProfileViewModel.swift create mode 100644 Mastodon/Scene/Profile/ProfileViewModel.swift create mode 100644 Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift create mode 100644 Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift create mode 100644 Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift create mode 100644 Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift create mode 100644 Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift create mode 100644 Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift create mode 100644 Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift create mode 100644 Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift create mode 100644 Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift delete mode 100644 Mastodon/Scene/Share/NavigationController/DarkContentStatusBarStyleNavigationController.swift create mode 100644 Mastodon/Service/APIService/APIService+Relationship.swift create mode 100644 Mastodon/Service/APIService/APIService+UserTimeline.swift create mode 100644 Mastodon/ViewController.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index c9ebac45f..db4dc4c23 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,11 +1,11 @@ - + - + @@ -22,7 +22,7 @@ - + @@ -32,7 +32,7 @@ - + @@ -49,7 +49,7 @@ - + @@ -72,17 +72,38 @@ + + + + + + + - - + + + + + + + + + + + + - - - - + + + + + + + + @@ -93,7 +114,7 @@ - + @@ -105,7 +126,7 @@ - + @@ -117,15 +138,13 @@ - - - - - - - + + + + + - + @@ -145,23 +164,31 @@ - - + + - + - - - + + + - - - - + + + + - - - + + + + + + + + + + + @@ -170,11 +197,12 @@ - + - + + \ No newline at end of file diff --git a/CoreDataStack/CoreDataStack.swift b/CoreDataStack/CoreDataStack.swift index 02aa397ff..1d13ee5ee 100644 --- a/CoreDataStack/CoreDataStack.swift +++ b/CoreDataStack/CoreDataStack.swift @@ -38,7 +38,7 @@ public final class CoreDataStack { }() static func persistentContainer() -> NSPersistentContainer { - let bundles = [Bundle(for: Toot.self)] + let bundles = [Bundle(for: Status.self)] guard let managedObjectModel = NSManagedObjectModel.mergedModel(from: bundles) else { fatalError("cannot locate bundles") } diff --git a/CoreDataStack/Entity/Application.swift b/CoreDataStack/Entity/Application.swift index c9aa22833..b40b2e5b2 100644 --- a/CoreDataStack/Entity/Application.swift +++ b/CoreDataStack/Entity/Application.swift @@ -17,8 +17,8 @@ public final class Application: NSManagedObject { @NSManaged public private(set) var website: String? @NSManaged public private(set) var vapidKey: String? - // one-to-many relationship - @NSManaged public private(set) var toots: Set + // one-to-one relationship + @NSManaged public private(set) var status: Status } public extension Application { diff --git a/CoreDataStack/Entity/Attachment.swift b/CoreDataStack/Entity/Attachment.swift index 33a0c0826..16f007bf1 100644 --- a/CoreDataStack/Entity/Attachment.swift +++ b/CoreDataStack/Entity/Attachment.swift @@ -28,7 +28,7 @@ public final class Attachment: NSManagedObject { @NSManaged public private(set) var index: NSNumber // many-to-one relastionship - @NSManaged public private(set) var toot: Toot? + @NSManaged public private(set) var status: Status? } diff --git a/CoreDataStack/Entity/Emoji.swift b/CoreDataStack/Entity/Emoji.swift index 933baab96..e9ee9d235 100644 --- a/CoreDataStack/Entity/Emoji.swift +++ b/CoreDataStack/Entity/Emoji.swift @@ -20,7 +20,7 @@ public final class Emoji: NSManagedObject { @NSManaged public private(set) var category: String? // many-to-one relationship - @NSManaged public private(set) var toot: Toot? + @NSManaged public private(set) var status: Status? } public extension Emoji { diff --git a/CoreDataStack/Entity/HomeTimelineIndex.swift b/CoreDataStack/Entity/HomeTimelineIndex.swift index 192e06e52..a902f5ce5 100644 --- a/CoreDataStack/Entity/HomeTimelineIndex.swift +++ b/CoreDataStack/Entity/HomeTimelineIndex.swift @@ -22,7 +22,7 @@ final public class HomeTimelineIndex: NSManagedObject { // many-to-one relationship - @NSManaged public private(set) var toot: Toot + @NSManaged public private(set) var status: Status } @@ -32,16 +32,16 @@ extension HomeTimelineIndex { public static func insert( into context: NSManagedObjectContext, property: Property, - toot: Toot + status: Status ) -> HomeTimelineIndex { let index: HomeTimelineIndex = context.insertObject() index.identifier = property.identifier index.domain = property.domain index.userID = property.userID - index.createdAt = toot.createdAt + index.createdAt = status.createdAt - index.toot = toot + index.status = status return index } diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index dc88d48a2..2228787b5 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -21,24 +21,44 @@ final public class MastodonUser: NSManagedObject { @NSManaged public private(set) var displayName: String @NSManaged public private(set) var avatar: String @NSManaged public private(set) var avatarStatic: String? + @NSManaged public private(set) var header: String + @NSManaged public private(set) var headerStatic: String? + @NSManaged public private(set) var note: String? + @NSManaged public private(set) var url: String? + @NSManaged public private(set) var statusesCount: NSNumber + @NSManaged public private(set) var followingCount: NSNumber + @NSManaged public private(set) var followersCount: NSNumber @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date // one-to-one relationship - @NSManaged public private(set) var pinnedToot: Toot? + @NSManaged public private(set) var pinnedStatus: Status? @NSManaged public private(set) var mastodonAuthentication: MastodonAuthentication? // one-to-many relationship - @NSManaged public private(set) var toots: Set? + @NSManaged public private(set) var statuses: Set? // many-to-many relationship - @NSManaged public private(set) var favourite: Set? - @NSManaged public private(set) var reblogged: Set? - @NSManaged public private(set) var muted: Set? - @NSManaged public private(set) var bookmarked: Set? + @NSManaged public private(set) var favourite: Set? + @NSManaged public private(set) var reblogged: Set? + @NSManaged public private(set) var muted: Set? + @NSManaged public private(set) var bookmarked: Set? @NSManaged public private(set) var votePollOptions: Set? @NSManaged public private(set) var votePolls: Set? + // relationships + @NSManaged public private(set) var following: Set? + @NSManaged public private(set) var followingBy: Set? + @NSManaged public private(set) var followRequested: Set? + @NSManaged public private(set) var followRequestedBy: Set? + @NSManaged public private(set) var muting: Set? + @NSManaged public private(set) var mutingBy: Set? + @NSManaged public private(set) var blocking: Set? + @NSManaged public private(set) var blockingBy: Set? + @NSManaged public private(set) var endorsed: Set? + @NSManaged public private(set) var endorsedBy: Set? + @NSManaged public private(set) var domainBlocking: Set? + @NSManaged public private(set) var domainBlockingBy: Set? } @@ -60,6 +80,16 @@ extension MastodonUser { user.displayName = property.displayName user.avatar = property.avatar user.avatarStatic = property.avatarStatic + user.header = property.header + user.headerStatic = property.headerStatic + user.note = property.note + user.url = property.url + user.statusesCount = NSNumber(value: property.statusesCount) + user.followingCount = NSNumber(value: property.followingCount) + user.followersCount = NSNumber(value: property.followersCount) + + // Mastodon do not provide relationship on the `Account` + // Update relationship via attribute updating interface user.createdAt = property.createdAt user.updatedAt = property.networkDate @@ -93,6 +123,107 @@ extension MastodonUser { self.avatarStatic = avatarStatic } } + public func update(header: String) { + if self.header != header { + self.header = header + } + } + public func update(headerStatic: String?) { + if self.headerStatic != headerStatic { + self.headerStatic = headerStatic + } + } + public func update(note: String?) { + if self.note != note { + self.note = note + } + } + public func update(url: String?) { + if self.url != url { + self.url = url + } + } + public func update(statusesCount: Int) { + if self.statusesCount.intValue != statusesCount { + self.statusesCount = NSNumber(value: statusesCount) + } + } + public func update(followingCount: Int) { + if self.followingCount.intValue != followingCount { + self.followingCount = NSNumber(value: followingCount) + } + } + public func update(followersCount: Int) { + if self.followersCount.intValue != followersCount { + self.followersCount = NSNumber(value: followersCount) + } + } + public func update(isFollowing: Bool, by mastodonUser: MastodonUser) { + if isFollowing { + if !(self.followingBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.followingBy)).add(mastodonUser) + } + } else { + if (self.followingBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.followingBy)).remove(mastodonUser) + } + } + } + public func update(isFollowRequested: Bool, by mastodonUser: MastodonUser) { + if isFollowRequested { + if !(self.followRequestedBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.followRequestedBy)).add(mastodonUser) + } + } else { + if (self.followRequestedBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.followRequestedBy)).remove(mastodonUser) + } + } + } + public func update(isMuting: Bool, by mastodonUser: MastodonUser) { + if isMuting { + if !(self.mutingBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.mutingBy)).add(mastodonUser) + } + } else { + if (self.mutingBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.mutingBy)).remove(mastodonUser) + } + } + } + public func update(isBlocking: Bool, by mastodonUser: MastodonUser) { + if isBlocking { + if !(self.blockingBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.blockingBy)).add(mastodonUser) + } + } else { + if (self.blockingBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.blockingBy)).remove(mastodonUser) + } + } + } + public func update(isEndorsed: Bool, by mastodonUser: MastodonUser) { + if isEndorsed { + if !(self.endorsedBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.endorsedBy)).add(mastodonUser) + } + } else { + if (self.endorsedBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.endorsedBy)).remove(mastodonUser) + } + } + } + public func update(isDomainBlocking: Bool, by mastodonUser: MastodonUser) { + if isDomainBlocking { + if !(self.domainBlockingBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.domainBlockingBy)).add(mastodonUser) + } + } else { + if (self.domainBlockingBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.domainBlockingBy)).remove(mastodonUser) + } + } + } public func didUpdate(at networkDate: Date) { self.updatedAt = networkDate @@ -100,8 +231,8 @@ extension MastodonUser { } -public extension MastodonUser { - struct Property { +extension MastodonUser { + public struct Property { public let identifier: String public let domain: String @@ -111,6 +242,13 @@ public extension MastodonUser { public let displayName: String public let avatar: String public let avatarStatic: String? + public let header: String + public let headerStatic: String? + public let note: String? + public let url: String? + public let statusesCount: Int + public let followingCount: Int + public let followersCount: Int public let createdAt: Date public let networkDate: Date @@ -123,6 +261,13 @@ public extension MastodonUser { displayName: String, avatar: String, avatarStatic: String?, + header: String, + headerStatic: String?, + note: String?, + url: String?, + statusesCount: Int, + followingCount: Int, + followersCount: Int, createdAt: Date, networkDate: Date ) { @@ -134,6 +279,13 @@ public extension MastodonUser { self.displayName = displayName self.avatar = avatar self.avatarStatic = avatarStatic + self.header = header + self.headerStatic = headerStatic + self.note = note + self.url = url + self.statusesCount = statusesCount + self.followingCount = followingCount + self.followersCount = followersCount self.createdAt = createdAt self.networkDate = networkDate } diff --git a/CoreDataStack/Entity/Mention.swift b/CoreDataStack/Entity/Mention.swift index e659cf891..9559ea5d5 100644 --- a/CoreDataStack/Entity/Mention.swift +++ b/CoreDataStack/Entity/Mention.swift @@ -19,7 +19,7 @@ public final class Mention: NSManagedObject { @NSManaged public private(set) var url: String // many-to-one relationship - @NSManaged public private(set) var toot: Toot + @NSManaged public private(set) var status: Status } public extension Mention { diff --git a/CoreDataStack/Entity/Poll.swift b/CoreDataStack/Entity/Poll.swift index 356f2fc2e..3ab48b444 100644 --- a/CoreDataStack/Entity/Poll.swift +++ b/CoreDataStack/Entity/Poll.swift @@ -22,7 +22,7 @@ public final class Poll: NSManagedObject { @NSManaged public private(set) var updatedAt: Date // one-to-one relationship - @NSManaged public private(set) var toot: Toot + @NSManaged public private(set) var status: Status // one-to-many relationship @NSManaged public private(set) var options: Set diff --git a/CoreDataStack/Entity/PrivateNote.swift b/CoreDataStack/Entity/PrivateNote.swift new file mode 100644 index 000000000..2e02db25c --- /dev/null +++ b/CoreDataStack/Entity/PrivateNote.swift @@ -0,0 +1,56 @@ +// +// PrivateNote.swift +// CoreDataStack +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import CoreData +import Foundation + +final public class PrivateNote: NSManagedObject { + + @NSManaged public private(set) var note: String? + + @NSManaged public private(set) var updatedAt: Date + + // many-to-one relationship + @NSManaged public private(set) var to: MastodonUser? + @NSManaged public private(set) var from: MastodonUser + +} + +extension PrivateNote { + public override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(PrivateNote.updatedAt)) + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> PrivateNote { + let privateNode: PrivateNote = context.insertObject() + privateNode.note = property.note + return privateNode + } +} + +extension PrivateNote { + public struct Property { + public let note: String? + + init(note: String) { + self.note = note + } + } + +} + +extension PrivateNote: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \PrivateNote.updatedAt, ascending: false)] + } +} + diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Status.swift similarity index 75% rename from CoreDataStack/Entity/Toot.swift rename to CoreDataStack/Entity/Status.swift index 43e728283..f40f78639 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Status.swift @@ -8,7 +8,7 @@ import CoreData import Foundation -public final class Toot: NSManagedObject { +public final class Status: NSManagedObject { public typealias ID = String @NSManaged public private(set) var identifier: ID @@ -30,7 +30,7 @@ public final class Toot: NSManagedObject { @NSManaged public private(set) var repliesCount: NSNumber? @NSManaged public private(set) var url: String? - @NSManaged public private(set) var inReplyToID: Toot.ID? + @NSManaged public private(set) var inReplyToID: Status.ID? @NSManaged public private(set) var inReplyToAccountID: MastodonUser.ID? @NSManaged public private(set) var language: String? // (ISO 639 Part 1 two-letter language code) @@ -38,8 +38,8 @@ public final class Toot: NSManagedObject { // many-to-one relastionship @NSManaged public private(set) var author: MastodonUser - @NSManaged public private(set) var reblog: Toot? - @NSManaged public private(set) var replyTo: Toot? + @NSManaged public private(set) var reblog: Status? + @NSManaged public private(set) var replyTo: Status? // many-to-many relastionship @NSManaged public private(set) var favouritedBy: Set? @@ -52,27 +52,27 @@ public final class Toot: NSManagedObject { @NSManaged public private(set) var poll: Poll? // one-to-many relationship - @NSManaged public private(set) var reblogFrom: Set? + @NSManaged public private(set) var reblogFrom: Set? @NSManaged public private(set) var mentions: Set? @NSManaged public private(set) var emojis: Set? @NSManaged public private(set) var tags: Set? @NSManaged public private(set) var homeTimelineIndexes: Set? @NSManaged public private(set) var mediaAttachments: Set? - @NSManaged public private(set) var replyFrom: Set? + @NSManaged public private(set) var replyFrom: Set? @NSManaged public private(set) var updatedAt: Date @NSManaged public private(set) var deletedAt: Date? } -public extension Toot { +public extension Status { @discardableResult static func insert( into context: NSManagedObjectContext, property: Property, author: MastodonUser, - reblog: Toot?, + reblog: Status?, application: Application?, - replyTo: Toot?, + replyTo: Status?, poll: Poll?, mentions: [Mention]?, emojis: [Emoji]?, @@ -83,8 +83,8 @@ public extension Toot { mutedBy: MastodonUser?, bookmarkedBy: MastodonUser?, pinnedBy: MastodonUser? - ) -> Toot { - let toot: Toot = context.insertObject() + ) -> Status { + let toot: Status = context.insertObject() toot.identifier = property.identifier toot.domain = property.domain @@ -117,28 +117,28 @@ public extension Toot { toot.poll = poll if let mentions = mentions { - toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: mentions) + toot.mutableSetValue(forKey: #keyPath(Status.mentions)).addObjects(from: mentions) } if let emojis = emojis { - toot.mutableSetValue(forKey: #keyPath(Toot.emojis)).addObjects(from: emojis) + toot.mutableSetValue(forKey: #keyPath(Status.emojis)).addObjects(from: emojis) } if let tags = tags { - toot.mutableSetValue(forKey: #keyPath(Toot.tags)).addObjects(from: tags) + toot.mutableSetValue(forKey: #keyPath(Status.tags)).addObjects(from: tags) } if let mediaAttachments = mediaAttachments { - toot.mutableSetValue(forKey: #keyPath(Toot.mediaAttachments)).addObjects(from: mediaAttachments) + toot.mutableSetValue(forKey: #keyPath(Status.mediaAttachments)).addObjects(from: mediaAttachments) } if let favouritedBy = favouritedBy { - toot.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).add(favouritedBy) + toot.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(favouritedBy) } if let rebloggedBy = rebloggedBy { - toot.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).add(rebloggedBy) + toot.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(rebloggedBy) } if let mutedBy = mutedBy { - toot.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).add(mutedBy) + toot.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mutedBy) } if let bookmarkedBy = bookmarkedBy { - toot.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(bookmarkedBy) + toot.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(bookmarkedBy) } toot.updatedAt = property.networkDate @@ -167,56 +167,56 @@ public extension Toot { } } - func update(replyTo: Toot?) { + func update(replyTo: Status?) { if self.replyTo != replyTo { self.replyTo = replyTo } } - func update(liked: Bool, mastodonUser: MastodonUser) { + func update(liked: Bool, by mastodonUser: MastodonUser) { if liked { if !(self.favouritedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).add(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(mastodonUser) } } else { if (self.favouritedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).remove(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).remove(mastodonUser) } } } - func update(reblogged: Bool, mastodonUser: MastodonUser) { + func update(reblogged: Bool, by mastodonUser: MastodonUser) { if reblogged { if !(self.rebloggedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).add(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(mastodonUser) } } else { if (self.rebloggedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).remove(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).remove(mastodonUser) } } } - func update(muted: Bool, mastodonUser: MastodonUser) { + func update(muted: Bool, by mastodonUser: MastodonUser) { if muted { if !(self.mutedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).add(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mastodonUser) } } else { if (self.mutedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).remove(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).remove(mastodonUser) } } } - func update(bookmarked: Bool, mastodonUser: MastodonUser) { + func update(bookmarked: Bool, by mastodonUser: MastodonUser) { if bookmarked { if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(mastodonUser) } } else { if (self.bookmarkedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).remove(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).remove(mastodonUser) } } } @@ -227,7 +227,7 @@ public extension Toot { } -public extension Toot { +public extension Status { struct Property { public let identifier: ID @@ -247,7 +247,7 @@ public extension Toot { public let repliesCount: NSNumber? public let url: String? - public let inReplyToID: Toot.ID? + public let inReplyToID: Status.ID? public let inReplyToAccountID: MastodonUser.ID? public let language: String? // (ISO 639 Part @1 two-letter language code) public let text: String? @@ -267,7 +267,7 @@ public extension Toot { favouritesCount: NSNumber, repliesCount: NSNumber?, url: String?, - inReplyToID: Toot.ID?, + inReplyToID: Status.ID?, inReplyToAccountID: MastodonUser.ID?, language: String?, text: String?, @@ -296,20 +296,20 @@ public extension Toot { } } -extension Toot: Managed { +extension Status: Managed { public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \Toot.createdAt, ascending: false)] + return [NSSortDescriptor(keyPath: \Status.createdAt, ascending: false)] } } -extension Toot { +extension Status { static func predicate(domain: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Toot.domain), domain) + return NSPredicate(format: "%K == %@", #keyPath(Status.domain), domain) } static func predicate(id: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Toot.id), id) + return NSPredicate(format: "%K == %@", #keyPath(Status.id), id) } public static func predicate(domain: String, id: String) -> NSPredicate { @@ -320,7 +320,7 @@ extension Toot { } static func predicate(ids: [String]) -> NSPredicate { - return NSPredicate(format: "%K IN %@", #keyPath(Toot.id), ids) + return NSPredicate(format: "%K IN %@", #keyPath(Status.id), ids) } public static func predicate(domain: String, ids: [String]) -> NSPredicate { @@ -331,10 +331,10 @@ extension Toot { } public static func notDeleted() -> NSPredicate { - return NSPredicate(format: "%K == nil", #keyPath(Toot.deletedAt)) + return NSPredicate(format: "%K == nil", #keyPath(Status.deletedAt)) } public static func deleted() -> NSPredicate { - return NSPredicate(format: "%K != nil", #keyPath(Toot.deletedAt)) + return NSPredicate(format: "%K != nil", #keyPath(Status.deletedAt)) } } diff --git a/CoreDataStack/Entity/Tag.swift b/CoreDataStack/Entity/Tag.swift index d817c774b..2f1914c4a 100644 --- a/CoreDataStack/Entity/Tag.swift +++ b/CoreDataStack/Entity/Tag.swift @@ -17,7 +17,7 @@ public final class Tag: NSManagedObject { @NSManaged public private(set) var url: String // many-to-many relationship - @NSManaged public private(set) var toot: Toot + @NSManaged public private(set) var statuses: Set? // one-to-many relationship @NSManaged public private(set) var histories: Set? diff --git a/Localization/app.json b/Localization/app.json index 99868a8fb..4e154be9c 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -32,6 +32,7 @@ "edit": "Edit", "save": "Save", "ok": "OK", + "done": "Done", "confirm": "Confirm", "continue": "Continue", "cancel": "Cancel", @@ -65,6 +66,15 @@ "closed": "Closed" } }, + "firendship": { + "follow": "Follow", + "following": "Following", + "block": "Block", + "blocked": "Blocked", + "mute": "Mute", + "muted": "Muted", + "edit_info": "Edit info" + }, "timeline": { "loader": { "load_missing_posts": "Load missing posts", @@ -231,6 +241,18 @@ "private": "Followers only", "direct": "Only people I mention" } + }, + "profile": { + "dashboard": { + "posts": "posts", + "following": "following", + "followers": "followers" + }, + "segmented_control": { + "posts": "Posts", + "replies": "Replies", + "media": "Media" + } } } } \ No newline at end of file diff --git a/Localization/ios-infoPlist.json b/Localization/ios-infoPlist.json index 0a260c273..f25dbcc0e 100644 --- a/Localization/ios-infoPlist.json +++ b/Localization/ios-infoPlist.json @@ -1,4 +1,4 @@ { - "NSCameraUsageDescription": "Used to take photo for toot", + "NSCameraUsageDescription": "Used to take photo for post status", "NSPhotoLibraryAddUsageDescription": "Used to save photo into the Photo Library" -} +} \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 22d59f747..03a831f36 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -43,7 +43,7 @@ 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */; }; 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */; }; 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 2D42FF6025C8177C004A627A /* ActiveLabel */; }; - 2D42FF6B25C817D2004A627A /* TootContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* TootContent.swift */; }; + 2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */; }; 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; }; 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */; }; 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; }; @@ -109,7 +109,7 @@ DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; }; DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; }; DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; }; - DB084B5725CBC56C00F898ED /* Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Toot.swift */; }; + DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; }; DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; }; @@ -125,6 +125,9 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; }; + DB35FC1F2612F1D9006193C9 /* ProfileFriendshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */; }; + DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; }; + DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.swift */; }; DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; }; DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; }; DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; }; @@ -155,6 +158,9 @@ DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */; }; + DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */; }; + DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */; }; + DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */; }; DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; }; DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */; }; DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; }; @@ -173,7 +179,7 @@ DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729525F9F91600D60309 /* ComposeStatusSection.swift */; }; DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */; }; DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; - DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; }; + DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; }; @@ -190,7 +196,7 @@ DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; }; - DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */; }; + DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */; }; DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; }; DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; }; DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D44A2609C11900D12C0D /* PollOptionView.swift */; }; @@ -205,7 +211,7 @@ DB89BA1B25C1107F008580ED /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1825C1107F008580ED /* Collection.swift */; }; DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1925C1107F008580ED /* NSManagedObjectContext.swift */; }; DB89BA1D25C1107F008580ED /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1A25C1107F008580ED /* URL.swift */; }; - DB89BA2725C110B4008580ED /* Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA2625C110B4008580ED /* Toot.swift */; }; + DB89BA2725C110B4008580ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA2625C110B4008580ED /* Status.swift */; }; DB89BA3725C1145C008580ED /* CoreData.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA3525C1145C008580ED /* CoreData.xcdatamodeld */; }; DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA4125C1165F008580ED /* NetworkUpdatable.swift */; }; DB89BA4425C1165F008580ED /* Managed.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA4225C1165F008580ED /* Managed.swift */; }; @@ -244,9 +250,30 @@ DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; + DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; + DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; }; + DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; }; + DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */; }; + DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */; }; + DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */; }; + DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */; }; + DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */; }; + DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */; }; + DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525632612C988002F1F29 /* MeProfileViewModel.swift */; }; + DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */; }; + DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; }; DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */; }; + DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */; }; + DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */; }; + DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B2F261440A50045B23D /* UITabBarController.swift */; }; + DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B35261440BA0045B23D /* UINavigationController.swift */; }; + DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B7A261443AD0045B23D /* ViewController.swift */; }; + DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B88261454BA0045B23D /* CGImage.swift */; }; + DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */; }; + DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */; }; + DBCC3B9B261584A00045B23D /* PrivateNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B9A2615849F0045B23D /* PrivateNote.swift */; }; DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; @@ -343,7 +370,7 @@ 2D38F1FD25CD481700561493 /* StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProvider.swift; sourceTree = ""; }; 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisposeBagCollectable.swift; sourceTree = ""; }; 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITapGestureRecognizer.swift; sourceTree = ""; }; - 2D42FF6A25C817D2004A627A /* TootContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TootContent.swift; sourceTree = ""; }; + 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonStatusContent.swift; sourceTree = ""; }; 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionToolBarContainer.swift; sourceTree = ""; }; 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestExpandedButton.swift; sourceTree = ""; }; 2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = ""; }; @@ -412,7 +439,7 @@ DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift; sourceTree = ""; }; DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModel.swift; sourceTree = ""; }; DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; - DB084B5625CBC56C00F898ED /* Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toot.swift; sourceTree = ""; }; + DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = ""; }; @@ -429,6 +456,9 @@ DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = ""; }; DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollExpiresOptionCollectionViewCell.swift; sourceTree = ""; }; + DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFriendshipActionButton.swift; sourceTree = ""; }; + DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldView.swift; sourceTree = ""; }; + DB35FC2E26130172006193C9 /* MastodonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonField.swift; sourceTree = ""; }; DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = ""; }; DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -465,6 +495,9 @@ DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; + DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+State.swift"; sourceTree = ""; }; + DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; + DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+UserTimeline.swift"; sourceTree = ""; }; DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = ""; }; DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = ""; }; DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = ""; }; @@ -482,7 +515,7 @@ DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = ""; }; DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = ""; }; DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = ""; }; - DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = ""; }; + DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdaptiveStatusBarStyleNavigationController.swift; sourceTree = ""; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = ""; }; @@ -499,7 +532,7 @@ DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; }; - DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToTootContentCollectionViewCell.swift; sourceTree = ""; }; + DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentCollectionViewCell.swift; sourceTree = ""; }; DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = ""; }; DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = ""; }; DB87D44A2609C11900D12C0D /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; @@ -516,7 +549,7 @@ DB89BA1825C1107F008580ED /* Collection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; DB89BA1925C1107F008580ED /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; DB89BA1A25C1107F008580ED /* URL.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; - DB89BA2625C110B4008580ED /* Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toot.swift; sourceTree = ""; }; + DB89BA2625C110B4008580ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; DB89BA3625C1145C008580ED /* CoreData.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreData.xcdatamodel; sourceTree = ""; }; DB89BA4125C1165F008580ED /* NetworkUpdatable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkUpdatable.swift; sourceTree = ""; }; DB89BA4225C1165F008580ED /* Managed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Managed.swift; sourceTree = ""; }; @@ -554,9 +587,29 @@ DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; + DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = ""; }; + DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = ""; }; + DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = ""; }; + DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewController.swift; sourceTree = ""; }; + DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewController.swift; sourceTree = ""; }; + DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = ""; }; + DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewModel.swift; sourceTree = ""; }; + DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; + DBB525632612C988002F1F29 /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = ""; }; + DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardView.swift; sourceTree = ""; }; + DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardMeterView.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = ""; }; DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPublishService.swift; sourceTree = ""; }; + DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+Diffable.swift"; sourceTree = ""; }; + DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFetchedResultsController.swift; sourceTree = ""; }; + DBCC3B2F261440A50045B23D /* UITabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITabBarController.swift; sourceTree = ""; }; + DBCC3B35261440BA0045B23D /* UINavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UINavigationController.swift; sourceTree = ""; }; + DBCC3B7A261443AD0045B23D /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + DBCC3B88261454BA0045B23D /* CGImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGImage.swift; sourceTree = ""; }; + DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedProfileViewModel.swift; sourceTree = ""; }; + DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Relationship.swift"; sourceTree = ""; }; + DBCC3B9A2615849F0045B23D /* PrivateNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateNote.swift; sourceTree = ""; }; DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; @@ -576,6 +629,7 @@ 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */, 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */, + DBB525082611EAC0002F1F29 /* Tabman in Frameworks */, 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, @@ -834,6 +888,7 @@ children = ( 2D76319D25C151F600929FB9 /* Section */, 2D7631B125C159E700929FB9 /* Item */, + DBCBED2226132E1D00B49291 /* FetchedResultsController */, ); path = Diffiable; sourceTree = ""; @@ -958,7 +1013,7 @@ isa = PBXGroup; children = ( DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, - DB084B5625CBC56C00F898ED /* Toot.swift */, + DB084B5625CBC56C00F898ED /* Status.swift */, DB9D6C3725E508BE0051B173 /* Attachment.swift */, ); path = CoreDataStack; @@ -1039,6 +1094,7 @@ children = ( DB427DE325BAA00100D1B89D /* Info.plist */, DB89BA1025C10FF5008580ED /* Mastodon.entitlements */, + DBCC3B7A261443AD0045B23D /* ViewController.swift */, 2D76319C25C151DE00929FB9 /* Diffiable */, DB8AF52A25C13561002E6C99 /* State */, 2D61335525C1886800CAE157 /* Service */, @@ -1089,12 +1145,14 @@ DB98339B25C96DE600AD9700 /* APIService+Account.swift */, 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */, DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */, + DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */, DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */, DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */, DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, DB9A488326034BD7008B817C /* APIService+Status.swift */, DB9A488F26035963008B817C /* APIService+Media.swift */, + DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */, ); path = APIService; sourceTree = ""; @@ -1152,7 +1210,7 @@ DB68A04F25E9028800CFDF14 /* NavigationController */ = { isa = PBXGroup; children = ( - DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */, + DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */, ); path = NavigationController; sourceTree = ""; @@ -1191,7 +1249,7 @@ DB789A2125F9F76D0071ACA0 /* CollectionViewCell */ = { isa = PBXGroup; children = ( - DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */, + DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */, DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */, DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */, DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */, @@ -1249,7 +1307,7 @@ DB89BA2C25C110B7008580ED /* Entity */ = { isa = PBXGroup; children = ( - DB89BA2625C110B4008580ED /* Toot.swift */, + DB89BA2625C110B4008580ED /* Status.swift */, DB8AF52425C131D1002E6C99 /* MastodonUser.swift */, DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */, 2D927F0125C7E4F2004F19B8 /* Mention.swift */, @@ -1261,6 +1319,7 @@ DB9D6C2D25E504AC0051B173 /* Attachment.swift */, DB4481AC25EE155900BEFB67 /* Poll.swift */, DB4481B225EE16D000BEFB67 /* PollOption.swift */, + DBCC3B9A2615849F0045B23D /* PrivateNote.swift */, ); path = Entity; sourceTree = ""; @@ -1335,6 +1394,7 @@ 0FAA101B25E10E760017CCDE /* UIFont.swift */, 2D939AB425EDD8A90076FA61 /* String.swift */, 2D206B7F25F5F45E00143C56 /* UIImage.swift */, + DBCC3B88261454BA0045B23D /* CGImage.swift */, 2D206B8525F5FB0900143C56 /* Double.swift */, 2D206B9125F60EA700143C56 /* UIControl.swift */, 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */, @@ -1344,6 +1404,8 @@ 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, 2D84350425FF858100EECE90 /* UIScrollView.swift */, DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */, + DBCC3B2F261440A50045B23D /* UITabBarController.swift */, + DBCC3B35261440BA0045B23D /* UINavigationController.swift */, ); path = Extension; sourceTree = ""; @@ -1395,7 +1457,13 @@ DB9D6C0825E4F5A60051B173 /* Profile */ = { isa = PBXGroup; children = ( + DBB525132611EBB1002F1F29 /* Segmented */, + DBB525462611ED57002F1F29 /* Header */, + DBB5253B2611ECF5002F1F29 /* Timeline */, DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */, + DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */, + DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */, + DBB525632612C988002F1F29 /* MeProfileViewModel.swift */, ); path = Profile; sourceTree = ""; @@ -1425,7 +1493,8 @@ DB9E0D6925EDFFE500CFDD76 /* Helper */ = { isa = PBXGroup; children = ( - 2D42FF6A25C817D2004A627A /* TootContent.swift */, + 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */, + DB35FC2E26130172006193C9 /* MastodonField.swift */, ); path = Helper; sourceTree = ""; @@ -1447,6 +1516,65 @@ path = View; sourceTree = ""; }; + DBB525132611EBB1002F1F29 /* Segmented */ = { + isa = PBXGroup; + children = ( + DBB525262611EBDA002F1F29 /* Paging */, + DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */, + ); + path = Segmented; + sourceTree = ""; + }; + DBB525262611EBDA002F1F29 /* Paging */ = { + isa = PBXGroup; + children = ( + DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */, + DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */, + ); + path = Paging; + sourceTree = ""; + }; + DBB5253B2611ECF5002F1F29 /* Timeline */ = { + isa = PBXGroup; + children = ( + DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */, + DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */, + DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */, + DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */, + DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */, + ); + path = Timeline; + sourceTree = ""; + }; + DBB525462611ED57002F1F29 /* Header */ = { + isa = PBXGroup; + children = ( + DBB525732612D5A5002F1F29 /* View */, + DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */, + ); + path = Header; + sourceTree = ""; + }; + DBB525732612D5A5002F1F29 /* View */ = { + isa = PBXGroup; + children = ( + DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */, + DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */, + DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */, + DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */, + DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */, + ); + path = View; + sourceTree = ""; + }; + DBCBED2226132E1D00B49291 /* FetchedResultsController */ = { + isa = PBXGroup; + children = ( + DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, + ); + path = FetchedResultsController; + sourceTree = ""; + }; DBE0821A25CD382900FD6BBD /* Register */ = { isa = PBXGroup; children = ( @@ -1500,6 +1628,7 @@ 2D939AC725EE14620076FA61 /* CropViewController */, DB9A487D2603456B008B817C /* UITextView+Placeholder */, DBE64A8A260C49D200E6359A /* TwitterTextEditor */, + DBB525072611EAC0002F1F29 /* Tabman */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -1631,6 +1760,7 @@ 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */, DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */, DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, + DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -1815,6 +1945,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */, + DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */, DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */, 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, @@ -1822,14 +1954,17 @@ 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */, + DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */, DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */, 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */, + DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */, + DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, @@ -1843,6 +1978,7 @@ 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, + DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */, 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */, DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, @@ -1855,19 +1991,23 @@ 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, + DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */, 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */, + DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, + DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */, DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, + DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, @@ -1896,12 +2036,15 @@ 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, + DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */, + DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */, 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */, DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, + DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, @@ -1918,9 +2061,11 @@ 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */, 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, + DB35FC1F2612F1D9006193C9 /* ProfileFriendshipActionButton.swift in Sources */, DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, + DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, @@ -1936,11 +2081,12 @@ 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, + DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, - DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */, + DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */, 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, @@ -1948,15 +2094,18 @@ DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, + DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */, DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, + DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, + DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, - DB084B5725CBC56C00F898ED /* Toot.swift in Sources */, + DB084B5725CBC56C00F898ED /* Status.swift in Sources */, DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */, DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, @@ -1982,7 +2131,7 @@ 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, - 2D42FF6B25C817D2004A627A /* TootContent.swift in Sources */, + 2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */, 2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */, DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */, DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, @@ -1991,7 +2140,9 @@ 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, + DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, + DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */, DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */, DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, @@ -1999,18 +2150,22 @@ DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */, DB9A489026035963008B817C /* APIService+Media.swift in Sources */, + DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, + DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, + DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, + DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, - DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift in Sources */, + DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2041,11 +2196,12 @@ DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */, DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */, 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */, + DBCC3B9B261584A00045B23D /* PrivateNote.swift in Sources */, DB89BA3725C1145C008580ED /* CoreData.xcdatamodeld in Sources */, DB8AF52525C131D1002E6C99 /* MastodonUser.swift in Sources */, DB89BA1B25C1107F008580ED /* Collection.swift in Sources */, DB4481AD25EE155900BEFB67 /* Poll.swift in Sources */, - DB89BA2725C110B4008580ED /* Toot.swift in Sources */, + DB89BA2725C110B4008580ED /* Status.swift in Sources */, 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */, DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */, DB89BA4425C1165F008580ED /* Managed.swift in Sources */, @@ -2605,6 +2761,14 @@ minimumVersion = 1.4.1; }; }; + DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/uias/Tabman"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.11.0; + }; + }; DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/TwitterTextEditor"; @@ -2660,6 +2824,11 @@ package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; productName = "UITextView+Placeholder"; }; + DBB525072611EAC0002F1F29 /* Tabman */ = { + isa = XCSwiftPackageProductDependency; + package = DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */; + productName = Tabman; + }; DBE64A8A260C49D200E6359A /* TwitterTextEditor */ = { isa = XCSwiftPackageProductDependency; package = DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4f2051528..3850a59d5 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -55,6 +55,15 @@ "version": "6.1.0" } }, + { + "package": "Pageboy", + "repositoryURL": "https://github.com/uias/Pageboy", + "state": { + "branch": null, + "revision": "34ecb6e7c4e0e07494960ab2f7cc9a02293915a6", + "version": "3.6.2" + } + }, { "package": "swift-nio", "repositoryURL": "https://github.com/apple/swift-nio.git", @@ -82,6 +91,15 @@ "version": "5.0.0" } }, + { + "package": "Tabman", + "repositoryURL": "https://github.com/uias/Tabman", + "state": { + "branch": null, + "revision": "bce2c87659c0ed868e6ef0aa1e05a330e202533f", + "version": "2.11.0" + } + }, { "package": "ThirdPartyMailer", "repositoryURL": "https://github.com/vtourraine/ThirdPartyMailer.git", diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 6ed0b18f8..0f343c855 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -50,6 +50,9 @@ extension SceneCoordinator { // compose case compose(viewModel: ComposeViewModel) + // profile + case profile(viewModel: ProfileViewModel) + // misc case alertController(alertController: UIAlertController) @@ -119,17 +122,18 @@ extension SceneCoordinator { presentingViewController.show(viewController, sender: sender) case .showDetail: - let navigationController = UINavigationController(rootViewController: viewController) + let navigationController = AdaptiveStatusBarStyleNavigationController(rootViewController: viewController) presentingViewController.showDetailViewController(navigationController, sender: sender) case .modal(let animated, let completion): let modalNavigationController: UINavigationController = { if scene.isOnboarding { - return DarkContentStatusBarStyleNavigationController(rootViewController: viewController) + return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController) } else { return UINavigationController(rootViewController: viewController) } }() + modalNavigationController.modalPresentationCapturesStatusBarAppearance = true if let adaptivePresentationControllerDelegate = viewController as? UIAdaptivePresentationControllerDelegate { modalNavigationController.presentationController?.delegate = adaptivePresentationControllerDelegate } @@ -146,12 +150,15 @@ extension SceneCoordinator { sender?.navigationController?.pushViewController(viewController, animated: true) case .safariPresent(let animated, let completion): + viewController.modalPresentationCapturesStatusBarAppearance = true presentingViewController.present(viewController, animated: animated, completion: completion) case .activityViewControllerPresent(let animated, let completion): + viewController.modalPresentationCapturesStatusBarAppearance = true presentingViewController.present(viewController, animated: animated, completion: completion) case .alertController(let animated, let completion): + viewController.modalPresentationCapturesStatusBarAppearance = true presentingViewController.present(viewController, animated: animated, completion: completion) } @@ -197,6 +204,10 @@ private extension SceneCoordinator { let _viewController = ComposeViewController() _viewController.viewModel = viewModel viewController = _viewController + case .profile(let viewModel): + let _viewController = ProfileViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .alertController(let alertController): if let popoverPresentationController = alertController.popoverPresentationController { assert( diff --git a/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift new file mode 100644 index 000000000..704f2ab78 --- /dev/null +++ b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift @@ -0,0 +1,86 @@ +// +// StatusFetchedResultsController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class StatusFetchedResultsController: NSObject { + + var disposeBag = Set() + + let fetchedResultsController: NSFetchedResultsController + + // input + let domain = CurrentValueSubject(nil) + let statusIDs = CurrentValueSubject<[Mastodon.Entity.Status.ID], Never>([]) + + // output + let items = CurrentValueSubject<[NSManagedObjectID], Never>([]) + + init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate) { + self.domain.value = domain ?? "" + self.fetchedResultsController = { + let fetchRequest = Status.sortedFetchRequest + fetchRequest.predicate = Status.predicate(domain: domain ?? "", ids: []) + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + fetchedResultsController.delegate = self + + Publishers.CombineLatest( + self.domain.removeDuplicates().eraseToAnyPublisher(), + self.statusIDs.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] domain, ids in + guard let self = self else { return } + self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + Status.predicate(domain: domain ?? "", ids: ids), + additionalTweetPredicate + ]) + do { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } + +} + +// MARK: - NSFetchedResultsControllerDelegate +extension StatusFetchedResultsController: NSFetchedResultsControllerDelegate { + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let indexes = statusIDs.value + let objects = fetchedResultsController.fetchedObjects ?? [] + + let items: [NSManagedObjectID] = objects + .compactMap { object in + indexes.firstIndex(of: object.id).map { index in (index, object) } + } + .sorted { $0.0 < $1.0 } + .map { $0.1.objectID } + self.items.value = items + } +} diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index 63c73d3a4..cd07c8836 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -16,40 +16,44 @@ enum Item { case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusAttribute) // normal list - case toot(objectID: NSManagedObjectID, attribute: StatusAttribute) + case status(objectID: NSManagedObjectID, attribute: StatusAttribute) // loader case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID) - case publicMiddleLoader(tootID: String) + case publicMiddleLoader(statusID: String) case bottomLoader } protocol StatusContentWarningAttribute { - var isStatusTextSensitive: Bool { get set } - var isStatusSensitive: Bool { get set } + var isStatusTextSensitive: Bool? { get set } + var isStatusSensitive: Bool? { get set } } extension Item { - class StatusAttribute: Equatable, Hashable, StatusContentWarningAttribute { - var isStatusTextSensitive: Bool - var isStatusSensitive: Bool + class StatusAttribute: StatusContentWarningAttribute { + var isStatusTextSensitive: Bool? + var isStatusSensitive: Bool? - public init( - isStatusTextSensitive: Bool, - isStatusSensitive: Bool + init( + isStatusTextSensitive: Bool? = nil, + isStatusSensitive: Bool? = nil ) { self.isStatusTextSensitive = isStatusTextSensitive self.isStatusSensitive = isStatusSensitive } - static func == (lhs: Item.StatusAttribute, rhs: Item.StatusAttribute) -> Bool { - return lhs.isStatusTextSensitive == rhs.isStatusTextSensitive && - lhs.isStatusSensitive == rhs.isStatusSensitive - } - - func hash(into hasher: inout Hasher) { - hasher.combine(isStatusTextSensitive) - hasher.combine(isStatusSensitive) + // delay attribute init + func setupForStatus(status: Status) { + if isStatusTextSensitive == nil { + isStatusTextSensitive = { + guard let spoilerText = status.spoilerText, !spoilerText.isEmpty else { return false } + return true + }() + } + + if isStatusSensitive == nil { + isStatusSensitive = status.sensitive + } } } } @@ -59,7 +63,7 @@ extension Item: Equatable { switch (lhs, rhs) { case (.homeTimelineIndex(let objectIDLeft, _), .homeTimelineIndex(let objectIDRight, _)): return objectIDLeft == objectIDRight - case (.toot(let objectIDLeft, _), .toot(let objectIDRight, _)): + case (.status(let objectIDLeft, _), .status(let objectIDRight, _)): return objectIDLeft == objectIDRight case (.bottomLoader, .bottomLoader): return true @@ -78,7 +82,7 @@ extension Item: Hashable { switch self { case .homeTimelineIndex(let objectID, _): hasher.combine(objectID) - case .toot(let objectID, _): + case .status(let objectID, _): hasher.combine(objectID) case .publicMiddleLoader(let upper): hasher.combine(String(describing: Item.publicMiddleLoader.self)) diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 222c40246..e9785461a 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -49,14 +49,14 @@ extension ComposeStatusSection { ] collectionView, indexPath, item -> UICollectionViewCell? in switch item { case .replyTo(let repliedToStatusObjectID): - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToTootContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToTootContentCollectionViewCell + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentCollectionViewCell return cell - case .input(let replyToTootObjectID, let attribute): + case .input(let replyToStatusObjectID, let attribute): let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell cell.textEditorView.text = attribute.composeContent.value ?? "" managedObjectContext.perform { - guard let replyToTootObjectID = replyToTootObjectID, - let replyTo = managedObjectContext.object(with: replyToTootObjectID) as? Toot else { + guard let replyToStatusObjectID = replyToStatusObjectID, + let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else { cell.statusView.headerContainerStackView.isHidden = true return } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 809e3b3cc..5e891e13a 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -39,41 +39,41 @@ extension StatusSection { dependency: dependency, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, - toot: timelineIndex.toot, + status: timelineIndex.status, requestUserID: timelineIndex.userID, statusItemAttribute: attribute ) } cell.delegate = statusTableViewCellDelegate return cell - case .toot(let objectID, let attribute): + case .status(let objectID, let attribute): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" // configure cell managedObjectContext.performAndWait { - let toot = managedObjectContext.object(with: objectID) as! Toot + let status = managedObjectContext.object(with: objectID) as! Status StatusSection.configure( cell: cell, dependency: dependency, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, - toot: toot, + status: status, requestUserID: requestUserID, statusItemAttribute: attribute ) } cell.delegate = statusTableViewCellDelegate return cell - case .publicMiddleLoader(let upperTimelineTootID): + case .publicMiddleLoader(let upperTimelineStatusID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell cell.delegate = timelineMiddleLoaderTableViewCellDelegate - timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineTootID: upperTimelineTootID, timelineIndexobjectID: nil) + timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: upperTimelineStatusID, timelineIndexobjectID: nil) return cell case .homeMiddleLoader(let upperTimelineIndexObjectID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell cell.delegate = timelineMiddleLoaderTableViewCellDelegate - timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineTootID: nil, timelineIndexobjectID: upperTimelineIndexObjectID) + timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: nil, timelineIndexobjectID: upperTimelineIndexObjectID) return cell case .bottomLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell @@ -90,47 +90,50 @@ extension StatusSection { dependency: NeedsDependency, readableLayoutFrame: CGRect?, timestampUpdatePublisher: AnyPublisher, - toot: Toot, + status: Status, requestUserID: String, statusItemAttribute: Item.StatusAttribute ) { + // setup attribute + statusItemAttribute.setupForStatus(status: status.reblog ?? status) + // set header - StatusSection.configureHeader(cell: cell, toot: toot) - ManagedObjectObserver.observe(object: toot) + StatusSection.configureHeader(cell: cell, status: status) + ManagedObjectObserver.observe(object: status) .receive(on: DispatchQueue.main) .sink { _ in // do nothing } receiveValue: { change in guard case .update(let object) = change.changeType, - let newToot = object as? Toot else { return } - StatusSection.configureHeader(cell: cell, toot: newToot) + let newStatus = object as? Status else { return } + StatusSection.configureHeader(cell: cell, status: newStatus) } .store(in: &cell.disposeBag) // set name username cell.statusView.nameLabel.text = { - let author = (toot.reblog ?? toot).author + let author = (status.reblog ?? status).author return author.displayName.isEmpty ? author.username : author.displayName }() - cell.statusView.usernameLabel.text = "@" + (toot.reblog ?? toot).author.acct + cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct // set avatar - if let reblog = toot.reblog { + if let reblog = status.reblog { cell.statusView.avatarButton.isHidden = true cell.statusView.avatarStackedContainerButton.isHidden = false cell.statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: reblog.author.avatarImageURL())) - cell.statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: toot.author.avatarImageURL())) + cell.statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) } else { cell.statusView.avatarButton.isHidden = false cell.statusView.avatarStackedContainerButton.isHidden = true - cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: toot.author.avatarImageURL())) + cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) } // set text - cell.statusView.activeTextLabel.config(content: (toot.reblog ?? toot).content) + cell.statusView.activeTextLabel.configure(content: (status.reblog ?? status).content) // set status text content warning - let spoilerText = (toot.reblog ?? toot).spoilerText ?? "" - let isStatusTextSensitive = statusItemAttribute.isStatusTextSensitive + let isStatusTextSensitive = statusItemAttribute.isStatusTextSensitive ?? false + let spoilerText = (status.reblog ?? status).spoilerText ?? "" cell.statusView.isStatusTextSensitive = isStatusTextSensitive cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive) cell.statusView.contentWarningTitle.text = { @@ -142,7 +145,7 @@ extension StatusSection { }() // prepare media attachments - let mediaAttachments = Array((toot.reblog ?? toot).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } + let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } // set image let mosiacImageViewModel = MosaicImageViewModel(mediaAttachments: mediaAttachments) @@ -184,7 +187,7 @@ extension StatusSection { } } cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty - let isStatusSensitive = statusItemAttribute.isStatusSensitive + let isStatusSensitive = statusItemAttribute.isStatusSensitive ?? false cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive @@ -251,7 +254,7 @@ extension StatusSection { cell.statusView.playerContainerView.playerViewController.player = nil } // set poll - let poll = (toot.reblog ?? toot).poll + let poll = (status.reblog ?? status).poll StatusSection.configurePoll( cell: cell, poll: poll, @@ -278,10 +281,10 @@ extension StatusSection { } // toolbar - StatusSection.configureActionToolBar(cell: cell, toot: toot, requestUserID: requestUserID) + StatusSection.configureActionToolBar(cell: cell, status: status, requestUserID: requestUserID) // set date - let createdAt = (toot.reblog ?? toot).createdAt + let createdAt = (status.reblog ?? status).createdAt cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow timestampUpdatePublisher .sink { _ in @@ -290,34 +293,34 @@ extension StatusSection { .store(in: &cell.disposeBag) // observe model change - ManagedObjectObserver.observe(object: toot.reblog ?? toot) + ManagedObjectObserver.observe(object: status.reblog ?? status) .receive(on: DispatchQueue.main) .sink { _ in // do nothing } receiveValue: { change in guard case .update(let object) = change.changeType, - let toot = object as? Toot else { return } - StatusSection.configureActionToolBar(cell: cell, toot: toot, requestUserID: requestUserID) + let status = object as? Status else { return } + StatusSection.configureActionToolBar(cell: cell, status: status, requestUserID: requestUserID) - os_log("%{public}s[%{public}ld], %{public}s: reblog count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.reblogsCount.intValue) - os_log("%{public}s[%{public}ld], %{public}s: like count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.favouritesCount.intValue) + os_log("%{public}s[%{public}ld], %{public}s: reblog count label for status %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, status.id, status.reblogsCount.intValue) + os_log("%{public}s[%{public}ld], %{public}s: like count label for status %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, status.id, status.favouritesCount.intValue) } .store(in: &cell.disposeBag) } static func configureHeader( cell: StatusTableViewCell, - toot: Toot + status: Status ) { - if toot.reblog != nil { + if status.reblog != nil { cell.statusView.headerContainerStackView.isHidden = false cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage) cell.statusView.headerInfoLabel.text = { - let author = toot.author + let author = status.author let name = author.displayName.isEmpty ? author.username : author.displayName return L10n.Common.Controls.Status.userReblogged(name) }() - } else if let replyTo = toot.replyTo { + } else if let replyTo = status.replyTo { cell.statusView.headerContainerStackView.isHidden = false cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) cell.statusView.headerInfoLabel.text = { @@ -332,29 +335,29 @@ extension StatusSection { static func configureActionToolBar( cell: StatusTableViewCell, - toot: Toot, + status: Status, requestUserID: String ) { - let toot = toot.reblog ?? toot + let status = status.reblog ?? status // set reply let replyCountTitle: String = { - let count = toot.repliesCount?.intValue ?? 0 + let count = status.repliesCount?.intValue ?? 0 return StatusSection.formattedNumberTitleForActionButton(count) }() cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal) // set reblog - let isReblogged = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false + let isReblogged = status.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false let reblogCountTitle: String = { - let count = toot.reblogsCount.intValue + let count = status.reblogsCount.intValue return StatusSection.formattedNumberTitleForActionButton(count) }() cell.statusView.actionToolbarContainer.reblogButton.setTitle(reblogCountTitle, for: .normal) cell.statusView.actionToolbarContainer.isReblogButtonHighlight = isReblogged // set like - let isLike = toot.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false + let isLike = status.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false let favoriteCountTitle: String = { - let count = toot.favouritesCount.intValue + let count = status.favouritesCount.intValue return StatusSection.formattedNumberTitleForActionButton(count) }() cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal) diff --git a/Mastodon/Extension/ActiveLabel.swift b/Mastodon/Extension/ActiveLabel.swift index 9219701f5..614735ad1 100644 --- a/Mastodon/Extension/ActiveLabel.swift +++ b/Mastodon/Extension/ActiveLabel.swift @@ -14,40 +14,61 @@ extension ActiveLabel { enum Style { case `default` - case timelineHeaderView + case profileField } convenience init(style: Style) { self.init() - switch style { - case .default: - font = .preferredFont(forTextStyle: .body) - textColor = Asset.Colors.Label.primary.color - case .timelineHeaderView: - font = .preferredFont(forTextStyle: .footnote) - textColor = .secondaryLabel - } - numberOfLines = 0 lineSpacing = 5 mentionColor = Asset.Colors.Label.highlight.color hashtagColor = Asset.Colors.Label.highlight.color URLColor = Asset.Colors.Label.highlight.color + #if DEBUG text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + #endif + + switch style { + case .default: + font = .preferredFont(forTextStyle: .body) + textColor = Asset.Colors.Label.primary.color + case .profileField: + font = .preferredFont(forTextStyle: .body) + textColor = Asset.Colors.Label.primary.color + numberOfLines = 1 + } } } extension ActiveLabel { - func config(content: String) { + /// status content + func configure(content: String) { activeEntities.removeAll() - if let parseResult = try? TootContent.parse(toot: content) { + if let parseResult = try? MastodonStatusContent.parse(status: content) { text = parseResult.trimmed activeEntities = parseResult.activeEntities } else { text = "" } } + + /// account note + func configure(note: String) { + configure(content: note) + } } +extension ActiveLabel { + /// account field + func configure(field: String) { + activeEntities.removeAll() + if let parseResult = try? MastodonField.parse(field: field) { + text = parseResult.value + activeEntities = parseResult.activeEntities + } else { + text = "" + } + } +} diff --git a/Mastodon/Extension/CGImage.swift b/Mastodon/Extension/CGImage.swift new file mode 100644 index 000000000..252f1289a --- /dev/null +++ b/Mastodon/Extension/CGImage.swift @@ -0,0 +1,154 @@ +// +// CGImage.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-31. +// + +import CoreImage + +extension CGImage { + // Reference + // https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.709-6-201506-I!!PDF-E.pdf + // Luma Y = 0.2126R + 0.7152G + 0.0722B + var brightness: CGFloat? { + let context = CIContext() // default with metal accelerate + let ciImage = CIImage(cgImage: self) + let rec709Image = context.createCGImage( + ciImage, + from: ciImage.extent, + format: .RGBA8, + colorSpace: CGColorSpace(name: CGColorSpace.itur_709) // BT.709 a.k.a Rec.709 + ) + guard let image = rec709Image, + image.bitsPerPixel == 32, + let data = rec709Image?.dataProvider?.data, + let pointer = CFDataGetBytePtr(data) else { return nil } + + let length = CFDataGetLength(data) + guard length > 0 else { return nil} + + var luma: CGFloat = 0.0 + for i in stride(from: 0, to: length, by: 4) { + let r = pointer[i] + let g = pointer[i + 1] + let b = pointer[i + 2] + let Y = 0.2126 * CGFloat(r) + 0.7152 * CGFloat(g) + 0.0722 * CGFloat(b) + luma += Y + } + luma /= CGFloat(width * height) + return luma + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI +import UIKit + +class BrightnessView: UIView { + let label = UILabel() + let imageView = UIImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + stackView.distribution = .fillEqually + stackView.addArrangedSubview(imageView) + stackView.addArrangedSubview(label) + + imageView.contentMode = .scaleAspectFill + imageView.layer.masksToBounds = true + label.textAlignment = .center + label.numberOfLines = 0 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setImage(_ image: UIImage) { + imageView.image = image + + guard let brightness = image.cgImage?.brightness, + let style = image.domainLumaCoefficientsStyle else { + label.text = "" + return + } + let styleDescription: String = { + switch style { + case .light: return "Light" + case .dark: return "Dark" + case .unspecified: fallthrough + @unknown default: + return "Unknown" + } + }() + + label.text = styleDescription + "\n" + "\(brightness)" + } +} + +struct CGImage_Brightness_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .black)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .gray)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .separator)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .red)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .green)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .blue)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .secondarySystemGroupedBackground)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + } + } + +} + +#endif + + diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index 7575704ba..471adb815 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -19,6 +19,13 @@ extension MastodonUser.Property { displayName: entity.displayName, avatar: entity.avatar, avatarStatic: entity.avatarStatic, + header: entity.header, + headerStatic: entity.headerStatic, + note: entity.note, + url: entity.url, + statusesCount: entity.statusesCount, + followingCount: entity.followingCount, + followersCount: entity.followersCount, createdAt: entity.createdAt, networkDate: networkDate ) @@ -26,7 +33,25 @@ extension MastodonUser.Property { } extension MastodonUser { + + var displayNameWithFallback: String { + return !displayName.isEmpty ? displayName : username + } + + var acctWithDomain: String { + return username + "@" + domain + } + +} + +extension MastodonUser { + + public func headerImageURL() -> URL? { + return URL(string: header) + } + public func avatarImageURL() -> URL? { return URL(string: avatar) } + } diff --git a/Mastodon/Extension/CoreDataStack/Toot.swift b/Mastodon/Extension/CoreDataStack/Status.swift similarity index 95% rename from Mastodon/Extension/CoreDataStack/Toot.swift rename to Mastodon/Extension/CoreDataStack/Status.swift index 2fab537e6..cf4f8a1bd 100644 --- a/Mastodon/Extension/CoreDataStack/Toot.swift +++ b/Mastodon/Extension/CoreDataStack/Status.swift @@ -1,5 +1,5 @@ // -// Toot.swift +// Status.swift // Mastodon // // Created by MainasuK Cirno on 2021/2/4. @@ -9,7 +9,7 @@ import Foundation import CoreDataStack import MastodonSDK -extension Toot.Property { +extension Status.Property { init(entity: Mastodon.Entity.Status, domain: String, networkDate: Date) { self.init( domain: domain, diff --git a/Mastodon/Extension/UIImage.swift b/Mastodon/Extension/UIImage.swift index 3c3c43400..072a3d4d4 100644 --- a/Mastodon/Extension/UIImage.swift +++ b/Mastodon/Extension/UIImage.swift @@ -39,6 +39,13 @@ extension UIImage { } } +extension UIImage { + var domainLumaCoefficientsStyle: UIUserInterfaceStyle? { + guard let brightness = cgImage?.brightness else { return nil } + return brightness > 100 ? .light : .dark // 0 ~ 255 + } +} + extension UIImage { func blur(radius: CGFloat) -> UIImage? { guard let inputImage = CIImage(image: self) else { return nil } diff --git a/Mastodon/Extension/UINavigationController.swift b/Mastodon/Extension/UINavigationController.swift new file mode 100644 index 000000000..54583e50a --- /dev/null +++ b/Mastodon/Extension/UINavigationController.swift @@ -0,0 +1,17 @@ +// +// UINavigationController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-31. +// + +import UIKit + +// This not works! +// SeeAlso: `AdaptiveStatusBarStyleNavigationController` +extension UINavigationController { + open override var childForStatusBarStyle: UIViewController? { + assertionFailure("Won't enter here") + return visibleViewController + } +} diff --git a/Mastodon/Extension/UITabBarController.swift b/Mastodon/Extension/UITabBarController.swift new file mode 100644 index 000000000..9b1d91292 --- /dev/null +++ b/Mastodon/Extension/UITabBarController.swift @@ -0,0 +1,14 @@ +// +// UITabBarController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-31. +// + +import UIKit + +extension UITabBarController { + open override var childForStatusBarStyle: UIViewController? { + return selectedViewController + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 82e7f8b1f..8276cfb20 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -38,6 +38,7 @@ internal enum Asset { internal static let disabled = ColorAsset(name: "Colors/Background/Poll/disabled") internal static let highlight = ColorAsset(name: "Colors/Background/Poll/highlight") } + internal static let alertYellow = ColorAsset(name: "Colors/Background/alert.yellow") internal static let dangerBorder = ColorAsset(name: "Colors/Background/danger.border") internal static let danger = ColorAsset(name: "Colors/Background/danger") internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 82fa696d8..5ce26bac8 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -60,6 +60,8 @@ internal enum L10n { internal static let `continue` = L10n.tr("Localizable", "Common.Controls.Actions.Continue") /// Discard internal static let discard = L10n.tr("Localizable", "Common.Controls.Actions.Discard") + /// Done + internal static let done = L10n.tr("Localizable", "Common.Controls.Actions.Done") /// Edit internal static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit") /// OK @@ -85,6 +87,22 @@ internal enum L10n { /// Try Again internal static let tryAgain = L10n.tr("Localizable", "Common.Controls.Actions.TryAgain") } + internal enum Firendship { + /// Block + internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block") + /// Blocked + internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked") + /// Edit info + internal static let editInfo = L10n.tr("Localizable", "Common.Controls.Firendship.EditInfo") + /// Follow + internal static let follow = L10n.tr("Localizable", "Common.Controls.Firendship.Follow") + /// Following + internal static let following = L10n.tr("Localizable", "Common.Controls.Firendship.Following") + /// Mute + internal static let mute = L10n.tr("Localizable", "Common.Controls.Firendship.Mute") + /// Muted + internal static let muted = L10n.tr("Localizable", "Common.Controls.Firendship.Muted") + } internal enum Status { /// Tap to reveal that may be sensitive internal static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning") @@ -263,6 +281,24 @@ internal enum L10n { internal static let publishing = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Publishing") } } + internal enum Profile { + internal enum Dashboard { + /// followers + internal static let followers = L10n.tr("Localizable", "Scene.Profile.Dashboard.Followers") + /// following + internal static let following = L10n.tr("Localizable", "Scene.Profile.Dashboard.Following") + /// posts + internal static let posts = L10n.tr("Localizable", "Scene.Profile.Dashboard.Posts") + } + internal enum SegmentedControl { + /// Media + internal static let media = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Media") + /// Posts + internal static let posts = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Posts") + /// Replies + internal static let replies = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Replies") + } + } internal enum PublicTimeline { /// Public internal static let title = L10n.tr("Localizable", "Scene.PublicTimeline.Title") diff --git a/Mastodon/Helper/MastodonField.swift b/Mastodon/Helper/MastodonField.swift new file mode 100644 index 000000000..cbe87c09b --- /dev/null +++ b/Mastodon/Helper/MastodonField.swift @@ -0,0 +1,48 @@ +// +// MastodonField.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import Foundation +import ActiveLabel + +enum MastodonField { + + static func parse(field string: String) -> ParseResult { + let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+))") + let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))") + let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)") + + var entities: [ActiveEntity] = [] + + for match in mentionMatches { + guard let text = string.substring(with: match, at: 0) else { continue } + let entity = ActiveEntity(range: match.range, type: .mention(text, userInfo: nil)) + entities.append(entity) + } + + for match in hashtagMatches { + guard let text = string.substring(with: match, at: 0) else { continue } + let entity = ActiveEntity(range: match.range, type: .hashtag(text, userInfo: nil)) + entities.append(entity) + } + + for match in urlMatches { + guard let text = string.substring(with: match, at: 0) else { continue } + let entity = ActiveEntity(range: match.range, type: .url(text, trimmed: text, url: text, userInfo: nil)) + entities.append(entity) + } + + return ParseResult(value: string, activeEntities: entities) + } + +} + +extension MastodonField { + struct ParseResult { + let value: String + let activeEntities: [ActiveEntity] + } +} diff --git a/Mastodon/Helper/TootContent.swift b/Mastodon/Helper/MastodonStatusContent.swift similarity index 83% rename from Mastodon/Helper/TootContent.swift rename to Mastodon/Helper/MastodonStatusContent.swift index 55f71beac..5b535b806 100755 --- a/Mastodon/Helper/TootContent.swift +++ b/Mastodon/Helper/MastodonStatusContent.swift @@ -1,5 +1,5 @@ // -// TootContent.swift +// MastodonStatusContent.swift // Mastodon // // Created by MainasuK Cirno on 2021/2/1. @@ -9,15 +9,15 @@ import Foundation import Kanna import ActiveLabel -enum TootContent { +enum MastodonStatusContent { - static func parse(toot: String) throws -> TootContent.ParseResult { - let toot = toot.replacingOccurrences(of: "
", with: "\n") - let rootNode = try Node.parse(document: toot) + static func parse(status: String) throws -> MastodonStatusContent.ParseResult { + let status = status.replacingOccurrences(of: "
", with: "\n") + let rootNode = try Node.parse(document: status) let text = String(rootNode.text) var activeEntities: [ActiveEntity] = [] - let entities = TootContent.Node.entities(in: rootNode) + let entities = MastodonStatusContent.Node.entities(in: rootNode) for entity in entities { let range = NSRange(entity.text.startIndex.. TootContent.Node { + static func parse(document: String) throws -> MastodonStatusContent.Node { let html = try HTML(html: document, encoding: .utf8) let body = html.body ?? nil let text = body?.text ?? "" let level = 0 - let children: [TootContent.Node] = body.flatMap { body in + let children: [MastodonStatusContent.Node] = body.flatMap { body in return Node.parse(element: body, parentText: text[...], parentLevel: level + 1) } ?? [] let node = Node( @@ -253,32 +252,32 @@ extension TootContent { } -extension TootContent.Node { +extension MastodonStatusContent.Node { enum `Type` { case url case mention case hashtag } - static func entities(in node: TootContent.Node) -> [TootContent.Node] { - return TootContent.Node.collect(node: node) { node in node.type != nil } + static func entities(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] { + return MastodonStatusContent.Node.collect(node: node) { node in node.type != nil } } - static func hashtags(in node: TootContent.Node) -> [TootContent.Node] { - return TootContent.Node.collect(node: node) { node in node.type == .hashtag } + static func hashtags(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] { + return MastodonStatusContent.Node.collect(node: node) { node in node.type == .hashtag } } - static func mentions(in node: TootContent.Node) -> [TootContent.Node] { - return TootContent.Node.collect(node: node) { node in node.type == .mention } + static func mentions(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] { + return MastodonStatusContent.Node.collect(node: node) { node in node.type == .mention } } - static func urls(in node: TootContent.Node) -> [TootContent.Node] { - return TootContent.Node.collect(node: node) { node in node.type == .url } + static func urls(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] { + return MastodonStatusContent.Node.collect(node: node) { node in node.type == .url } } } -extension TootContent.Node: CustomDebugStringConvertible { +extension MastodonStatusContent.Node: CustomDebugStringConvertible { var debugDescription: String { let linkInfo: String = { switch (href, hrefEllipsis) { diff --git a/Mastodon/Info.plist b/Mastodon/Info.plist index e3d8d4a91..266cc9424 100644 --- a/Mastodon/Info.plist +++ b/Mastodon/Info.plist @@ -73,7 +73,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - UIViewControllerBasedStatusBarAppearance - diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index f3d31ff33..ffaa29b52 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -13,6 +13,19 @@ import CoreDataStack import MastodonSDK import ActiveLabel +// MARK: - StatusViewDelegate +extension StatusTableViewCellDelegate where Self: StatusProvider { + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel) { + StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .secondary, provider: self, cell: cell) + } + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton) { + StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .primary, provider: self, cell: cell) + } + +} + // MARK: - ActionToolbarContainerDelegate extension StatusTableViewCellDelegate where Self: StatusProvider { @@ -31,7 +44,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { switch item { case .homeTimelineIndex(_, let attribute): attribute.isStatusTextSensitive = false - case .toot(_, let attribute): + case .status(_, let attribute): attribute.isStatusTextSensitive = false default: return @@ -66,7 +79,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { switch item { case .homeTimelineIndex(_, let attribute): attribute.isStatusSensitive = false - case .toot(_, let attribute): + case .status(_, let attribute): attribute.isStatusSensitive = false default: return @@ -89,16 +102,16 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - toot(for: cell, indexPath: nil) + status(for: cell, indexPath: nil) .receive(on: DispatchQueue.main) .setFailureType(to: Error.self) - .compactMap { toot -> AnyPublisher, Error>? in - guard let toot = (toot?.reblog ?? toot) else { return nil } - guard let poll = toot.poll else { return nil } + .compactMap { status -> AnyPublisher, Error>? in + guard let status = (status?.reblog ?? status) else { return nil } + guard let poll = status.poll else { return nil } let votedOptions = poll.options.filter { ($0.votedBy ?? Set()).contains(where: { $0.id == activeMastodonAuthenticationBox.userID }) } let choices = votedOptions.map { $0.index.intValue } - let domain = poll.toot.domain + let domain = poll.status.domain button.isEnabled = false @@ -137,7 +150,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { let poll = option.poll let pollObjectID = option.poll.objectID - let domain = poll.toot.domain + let domain = poll.status.domain if poll.multiple { var votedOptions = poll.options.filter { ($0.votedBy ?? Set()).contains(where: { $0.id == activeMastodonAuthenticationBox.userID }) } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift index 20f8e30a9..a0aaa543e 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift @@ -11,7 +11,7 @@ import CoreDataStack extension StatusTableViewCellDelegate where Self: StatusProvider { func handleTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - // prefetch reply toot + // prefetch reply status guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } let domain = activeMastodonAuthenticationBox.domain @@ -20,8 +20,8 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { switch item { case .homeTimelineIndex(let objectID, _): let homeTimelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex - statusObjectIDs.append(homeTimelineIndex.toot.objectID) - case .toot(let objectID, _): + statusObjectIDs.append(homeTimelineIndex.status.objectID) + case .status(let objectID, _): statusObjectIDs.append(objectID) default: continue @@ -32,15 +32,15 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { backgroundManagedObjectContext.perform { [weak self] in guard let self = self else { return } for objectID in statusObjectIDs { - let toot = backgroundManagedObjectContext.object(with: objectID) as! Toot - guard let replyToID = toot.inReplyToID, toot.replyTo == nil else { + let status = backgroundManagedObjectContext.object(with: objectID) as! Status + guard let replyToID = status.inReplyToID, status.replyTo == nil else { // skip continue } self.context.statusPrefetchingService.prefetchReplyTo( domain: domain, - statusObjectID: toot.objectID, - statusID: toot.id, + statusObjectID: status.objectID, + statusID: status.id, replyToStatusID: replyToID, authorizationBox: activeMastodonAuthenticationBox ) diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index 89ae8e6eb..baaa708a1 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -17,15 +17,15 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { // } func handleTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - // update poll when toot appear + // update poll when status appear let now = Date() var pollID: Mastodon.Entity.Poll.ID? - toot(for: cell, indexPath: indexPath) - .compactMap { [weak self] toot -> AnyPublisher, Error>? in + status(for: cell, indexPath: indexPath) + .compactMap { [weak self] status -> AnyPublisher, Error>? in guard let self = self else { return nil } guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return nil } - guard let toot = (toot?.reblog ?? toot) else { return nil } - guard let poll = toot.poll else { return nil } + guard let status = (status?.reblog ?? status) else { return nil } + guard let poll = status.poll else { return nil } pollID = poll.id // not expired AND last update > 60s @@ -46,7 +46,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info update…", (#file as NSString).lastPathComponent, #line, #function, poll.id) return self.context.apiService.poll( - domain: toot.domain, + domain: status.domain, pollID: poll.id, pollObjectID: poll.objectID, mastodonAuthenticationBox: authenticationBox @@ -68,11 +68,11 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { }) .store(in: &disposeBag) - toot(for: cell, indexPath: indexPath) - .sink { [weak self] toot in + status(for: cell, indexPath: indexPath) + .sink { [weak self] status in guard let self = self else { return } - let toot = toot?.reblog ?? toot - guard let media = (toot?.mediaAttachments ?? Set()).first else { return } + let status = status?.reblog ?? status + guard let media = (status?.mediaAttachments ?? Set()).first else { return } guard let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) else { return } DispatchQueue.main.async { @@ -85,17 +85,17 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { func handleTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { // os_log("%{public}s[%{public}ld], %{public}s: indexPath %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) - toot(for: cell, indexPath: indexPath) - .sink { [weak self] toot in + status(for: cell, indexPath: indexPath) + .sink { [weak self] status in guard let self = self else { return } - guard let media = (toot?.mediaAttachments ?? Set()).first else { return } + guard let media = (status?.mediaAttachments ?? Set()).first else { return } if let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) { DispatchQueue.main.async { videoPlayerViewModel.didEndDisplaying() } } - if let currentAudioAttachment = self.context.audioPlaybackService.attachment, let _ = toot?.mediaAttachments?.contains(currentAudioAttachment) { + if let currentAudioAttachment = self.context.audioPlaybackService.attachment, let _ = status?.mediaAttachments?.contains(currentAudioAttachment) { self.context.audioPlaybackService.pause() } } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider.swift b/Mastodon/Protocol/StatusProvider/StatusProvider.swift index 4ec0d1e16..e16343ee6 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider.swift @@ -12,9 +12,9 @@ import CoreDataStack protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController { // async - func toot() -> Future - func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future - func toot(for cell: UICollectionViewCell) -> Future + func status() -> Future + func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future + func status(for cell: UICollectionViewCell) -> Future // sync var managedObjectContext: NSManagedObjectContext { get } diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index cab16e68c..19b0fbf7e 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -13,8 +13,53 @@ import CoreDataStack import MastodonSDK import ActiveLabel -enum StatusProviderFacade { +enum StatusProviderFacade { } +extension StatusProviderFacade { + + static func coordinateToStatusAuthorProfileScene(for target: Target, provider: StatusProvider) { + _coordinateToStatusAuthorProfileScene( + for: target, + provider: provider, + status: provider.status() + ) + } + + static func coordinateToStatusAuthorProfileScene(for target: Target, provider: StatusProvider, cell: UITableViewCell) { + _coordinateToStatusAuthorProfileScene( + for: target, + provider: provider, + status: provider.status(for: cell, indexPath: nil) + ) + } + + private static func _coordinateToStatusAuthorProfileScene(for target: Target, provider: StatusProvider, status: Future) { + status + .sink { [weak provider] status in + guard let provider = provider else { return } + let _status: Status? = { + switch target { + case .primary: return status?.reblog ?? status // original status + case .secondary: return status?.replyTo ?? status // reblog or reply to status + } + }() + guard let status = _status else { return } + + let mastodonUser = status.author + let profileViewModel = CachedProfileViewModel(context: provider.context, mastodonUser: mastodonUser) + DispatchQueue.main.async { + if provider.navigationController == nil { + let from = provider.presentingViewController ?? provider + provider.dismiss(animated: true) { + provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: from, transition: .show) + } + } else { + provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: provider, transition: .show) + } + } + } + .store(in: &provider.disposeBag) + } } extension StatusProviderFacade { @@ -22,18 +67,18 @@ extension StatusProviderFacade { static func responseToStatusLikeAction(provider: StatusProvider) { _responseToStatusLikeAction( provider: provider, - toot: provider.toot() + status: provider.status() ) } static func responseToStatusLikeAction(provider: StatusProvider, cell: UITableViewCell) { _responseToStatusLikeAction( provider: provider, - toot: provider.toot(for: cell, indexPath: nil) + status: provider.status(for: cell, indexPath: nil) ) } - private static func _responseToStatusLikeAction(provider: StatusProvider, toot: Future) { + private static func _responseToStatusLikeAction(provider: StatusProvider, status: Future) { // prepare authentication guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { assertionFailure() @@ -55,22 +100,22 @@ extension StatusProviderFacade { let generator = UIImpactFeedbackGenerator(style: .light) let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) - toot - .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Favorites.FavoriteKind)? in - guard let toot = toot?.reblog ?? toot else { return nil } + status + .compactMap { status -> (NSManagedObjectID, Mastodon.API.Favorites.FavoriteKind)? in + guard let status = status?.reblog ?? status else { return nil } let favoriteKind: Mastodon.API.Favorites.FavoriteKind = { - let isLiked = toot.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false + let isLiked = status.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false return isLiked ? .destroy : .create }() - return (toot.objectID, favoriteKind) + return (status.objectID, favoriteKind) } - .map { tootObjectID, favoriteKind -> AnyPublisher<(Toot.ID, Mastodon.API.Favorites.FavoriteKind), Error> in + .map { statusObjectID, favoriteKind -> AnyPublisher<(Status.ID, Mastodon.API.Favorites.FavoriteKind), Error> in return context.apiService.like( - tootObjectID: tootObjectID, + statusObjectID: statusObjectID, mastodonUserObjectID: mastodonUserObjectID, favoriteKind: favoriteKind ) - .map { tootID in (tootID, favoriteKind) } + .map { statusID in (statusID, favoriteKind) } .eraseToAnyPublisher() } .setFailureType(to: Error.self) @@ -82,7 +127,7 @@ extension StatusProviderFacade { responseFeedbackGenerator.prepare() } receiveOutput: { _, favoriteKind in generator.impactOccurred() - os_log("%{public}s[%{public}ld], %{public}s: [Like] update local toot like status to: %s", ((#file as NSString).lastPathComponent), #line, #function, favoriteKind == .create ? "like" : "unlike") + os_log("%{public}s[%{public}ld], %{public}s: [Like] update local status like status to: %s", ((#file as NSString).lastPathComponent), #line, #function, favoriteKind == .create ? "like" : "unlike") } receiveCompletion: { completion in switch completion { case .failure: @@ -92,9 +137,9 @@ extension StatusProviderFacade { break } } - .map { tootID, favoriteKind in + .map { statusID, favoriteKind in return context.apiService.like( - statusID: tootID, + statusID: statusID, favoriteKind: favoriteKind, mastodonAuthenticationBox: activeMastodonAuthenticationBox ) @@ -126,18 +171,18 @@ extension StatusProviderFacade { static func responseToStatusReblogAction(provider: StatusProvider) { _responseToStatusReblogAction( provider: provider, - toot: provider.toot() + status: provider.status() ) } static func responseToStatusReblogAction(provider: StatusProvider, cell: UITableViewCell) { _responseToStatusReblogAction( provider: provider, - toot: provider.toot(for: cell, indexPath: nil) + status: provider.status(for: cell, indexPath: nil) ) } - private static func _responseToStatusReblogAction(provider: StatusProvider, toot: Future) { + private static func _responseToStatusReblogAction(provider: StatusProvider, status: Future) { // prepare authentication guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { assertionFailure() @@ -159,22 +204,22 @@ extension StatusProviderFacade { let generator = UIImpactFeedbackGenerator(style: .light) let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) - toot - .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Reblog.ReblogKind)? in - guard let toot = toot?.reblog ?? toot else { return nil } + status + .compactMap { status -> (NSManagedObjectID, Mastodon.API.Reblog.ReblogKind)? in + guard let status = status?.reblog ?? status else { return nil } let reblogKind: Mastodon.API.Reblog.ReblogKind = { - let isReblogged = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false + let isReblogged = status.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false return isReblogged ? .undoReblog : .reblog(query: .init(visibility: nil)) }() - return (toot.objectID, reblogKind) + return (status.objectID, reblogKind) } - .map { tootObjectID, reblogKind -> AnyPublisher<(Toot.ID, Mastodon.API.Reblog.ReblogKind), Error> in + .map { statusObjectID, reblogKind -> AnyPublisher<(Status.ID, Mastodon.API.Reblog.ReblogKind), Error> in return context.apiService.reblog( - tootObjectID: tootObjectID, + statusObjectID: statusObjectID, mastodonUserObjectID: mastodonUserObjectID, reblogKind: reblogKind ) - .map { tootID in (tootID, reblogKind) } + .map { statusID in (statusID, reblogKind) } .eraseToAnyPublisher() } .setFailureType(to: Error.self) @@ -188,9 +233,9 @@ extension StatusProviderFacade { generator.impactOccurred() switch reblogKind { case .reblog: - os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "reblog") + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local status reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "reblog") case .undoReblog: - os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "unreblog") + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local status reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "unreblog") } } receiveCompletion: { completion in switch completion { @@ -201,9 +246,9 @@ extension StatusProviderFacade { break } } - .map { tootID, reblogKind in + .map { statusID, reblogKind in return context.apiService.reblog( - statusID: tootID, + statusID: statusID, reblogKind: reblogKind, mastodonAuthenticationBox: activeMastodonAuthenticationBox ) @@ -231,8 +276,8 @@ extension StatusProviderFacade { extension StatusProviderFacade { enum Target { - case toot - case reblog + case primary // original + case secondary // attachment reblog or reply } } diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/alert.yellow.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/alert.yellow.colorset/Contents.json new file mode 100644 index 000000000..29b7bba3d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/alert.yellow.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.016", + "green" : "0.561", + "red" : "0.792" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/en.lproj/InfoPlist.strings b/Mastodon/Resources/en.lproj/InfoPlist.strings index 972e1a7a2..48566ae36 100644 --- a/Mastodon/Resources/en.lproj/InfoPlist.strings +++ b/Mastodon/Resources/en.lproj/InfoPlist.strings @@ -1,2 +1,2 @@ -"NSCameraUsageDescription" = "Used to take photo for toot"; +"NSCameraUsageDescription" = "Used to take photo for post status"; "NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library"; \ No newline at end of file diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index c2bc09c68..6f70412a6 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -15,6 +15,7 @@ Please check your internet connection."; "Common.Controls.Actions.Confirm" = "Confirm"; "Common.Controls.Actions.Continue" = "Continue"; "Common.Controls.Actions.Discard" = "Discard"; +"Common.Controls.Actions.Done" = "Done"; "Common.Controls.Actions.Edit" = "Edit"; "Common.Controls.Actions.Ok" = "OK"; "Common.Controls.Actions.OpenInSafari" = "Open in Safari"; @@ -27,6 +28,13 @@ Please check your internet connection."; "Common.Controls.Actions.SignUp" = "Sign Up"; "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Actions.TryAgain" = "Try Again"; +"Common.Controls.Firendship.Block" = "Block"; +"Common.Controls.Firendship.Blocked" = "Blocked"; +"Common.Controls.Firendship.EditInfo" = "Edit info"; +"Common.Controls.Firendship.Follow" = "Follow"; +"Common.Controls.Firendship.Following" = "Following"; +"Common.Controls.Firendship.Mute" = "Mute"; +"Common.Controls.Firendship.Muted" = "Muted"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; "Common.Controls.Status.Poll.Closed" = "Closed"; "Common.Controls.Status.Poll.TimeLeft" = "%@ left"; @@ -85,6 +93,12 @@ tap the link to confirm your account."; "Scene.HomeTimeline.NavigationBarState.Published" = "Published!"; "Scene.HomeTimeline.NavigationBarState.Publishing" = "Publishing post..."; "Scene.HomeTimeline.Title" = "Home"; +"Scene.Profile.Dashboard.Followers" = "followers"; +"Scene.Profile.Dashboard.Following" = "following"; +"Scene.Profile.Dashboard.Posts" = "posts"; +"Scene.Profile.SegmentedControl.Media" = "Media"; +"Scene.Profile.SegmentedControl.Posts" = "Posts"; +"Scene.Profile.SegmentedControl.Replies" = "Replies"; "Scene.PublicTimeline.Title" = "Public"; "Scene.Register.Error.Item.Agreement" = "Agreement"; "Scene.Register.Error.Item.Email" = "Email"; diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToTootContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift similarity index 62% rename from Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToTootContentCollectionViewCell.swift rename to Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift index fe00563df..0163a54cd 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToTootContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift @@ -1,5 +1,5 @@ // -// ComposeRepliedToTootContentCollectionViewCell.swift +// ComposeRepliedToStatusContentCollectionViewCell.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-11. @@ -7,7 +7,7 @@ import UIKit -final class ComposeRepliedToTootContentCollectionViewCell: UICollectionViewCell { +final class ComposeRepliedToStatusContentCollectionViewCell: UICollectionViewCell { override init(frame: CGRect) { super.init(frame: frame) @@ -21,7 +21,7 @@ final class ComposeRepliedToTootContentCollectionViewCell: UICollectionViewCell } -extension ComposeRepliedToTootContentCollectionViewCell { +extension ComposeRepliedToStatusContentCollectionViewCell { private func _init() { diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 53f71fc31..c4442bab3 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -43,7 +43,7 @@ final class ComposeViewController: UIViewController, NeedsDependency { let collectionView: UICollectionView = { let collectionViewLayout = ComposeViewController.createLayout() let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) - collectionView.register(ComposeRepliedToTootContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToTootContentCollectionViewCell.self)) + collectionView.register(ComposeRepliedToStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self)) collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self)) collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self)) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 6f289b339..785d97264 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -65,7 +65,7 @@ extension HomeTimelineViewController { guard let self = self else { return } self.moveToFirstVideoStatus(action) }), - UIAction(title: "First GIF Toot", image: nil, attributes: [], handler: { [weak self] action in + UIAction(title: "First GIF status", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } self.moveToFirstGIFStatus(action) }), @@ -112,7 +112,7 @@ extension HomeTimelineViewController { switch item { case .homeTimelineIndex(let objectID, _): let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - return homeTimelineIndex.toot.reblog != nil + return homeTimelineIndex.status.reblog != nil default: return false } @@ -132,7 +132,7 @@ extension HomeTimelineViewController { switch item { case .homeTimelineIndex(let objectID, _): let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let post = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot + let post = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status return post.poll != nil default: return false @@ -153,7 +153,7 @@ extension HomeTimelineViewController { switch item { case .homeTimelineIndex(let objectID, _): let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - guard homeTimelineIndex.toot.inReplyToID != nil else { + guard homeTimelineIndex.status.inReplyToID != nil else { return false } return true @@ -176,8 +176,8 @@ extension HomeTimelineViewController { switch item { case .homeTimelineIndex(let objectID, _): let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot - return toot.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false + let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return status.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false default: return false } @@ -186,7 +186,7 @@ extension HomeTimelineViewController { tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) tableView.blinkRow(at: IndexPath(row: index, section: 0)) } else { - print("Not found audio toot") + print("Not found audio status") } } @@ -197,8 +197,8 @@ extension HomeTimelineViewController { switch item { case .homeTimelineIndex(let objectID, _): let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot - return toot.mediaAttachments?.contains(where: { $0.type == .video }) ?? false + let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return status.mediaAttachments?.contains(where: { $0.type == .video }) ?? false default: return false } @@ -218,8 +218,8 @@ extension HomeTimelineViewController { switch item { case .homeTimelineIndex(let objectID, _): let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot - return toot.mediaAttachments?.contains(where: { $0.type == .gifv }) ?? false + let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return status.mediaAttachments?.contains(where: { $0.type == .gifv }) ?? false default: return false } @@ -242,12 +242,12 @@ extension HomeTimelineViewController { default: return nil } } - var droppingTootObjectIDs: [NSManagedObjectID] = [] + var droppingStatusObjectIDs: [NSManagedObjectID] = [] context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in guard let self = self else { return } for objectID in droppingObjectIDs { guard let homeTimelineIndex = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { continue } - droppingTootObjectIDs.append(homeTimelineIndex.toot.objectID) + droppingStatusObjectIDs.append(homeTimelineIndex.status.objectID) self.context.apiService.backgroundManagedObjectContext.delete(homeTimelineIndex) } } @@ -257,8 +257,8 @@ extension HomeTimelineViewController { case .success: self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in guard let self = self else { return } - for objectID in droppingTootObjectIDs { - guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Toot else { continue } + for objectID in droppingStatusObjectIDs { + guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Status else { continue } self.context.apiService.backgroundManagedObjectContext.delete(post) } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift index dc8eb4803..9e1915301 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift @@ -14,11 +14,11 @@ import CoreDataStack // MARK: - StatusProvider extension HomeTimelineViewController: StatusProvider { - func toot() -> Future { + func status() -> Future { return Future { promise in promise(.success(nil)) } } - func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() @@ -36,7 +36,7 @@ extension HomeTimelineViewController: StatusProvider { let managedObjectContext = self.viewModel.fetchedResultsController.managedObjectContext managedObjectContext.perform { let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex - promise(.success(timelineIndex?.toot)) + promise(.success(timelineIndex?.status)) } default: promise(.success(nil)) @@ -44,7 +44,7 @@ extension HomeTimelineViewController: StatusProvider { } } - func toot(for cell: UICollectionViewCell) -> Future { + func status(for cell: UICollectionViewCell) -> Future { return Future { promise in promise(.success(nil)) } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 078b7b445..1f3dea81a 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -175,6 +175,13 @@ extension HomeTimelineViewController { } .store(in: &disposeBag) } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // needs trigger manually after onboarding dismiss + setNeedsStatusBarAppearanceUpdate() + } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -313,7 +320,7 @@ extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControl // MARK: - TimelineMiddleLoaderTableViewCellDelegate extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) { + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) { guard let upperTimelineIndexObjectID = timelineIndexobjectID else { return } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index 4a34b922a..5f16a18eb 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -31,6 +31,10 @@ extension HomeTimelineViewModel { statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate ) + +// var snapshot = NSDiffableDataSourceSnapshot() +// snapshot.appendSections([.main]) +// diffableDataSource?.apply(snapshot) } } @@ -83,12 +87,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { var newTimelineItems: [Item] = [] for (i, timelineIndex) in timelineIndexes.enumerated() { - let toot = timelineIndex.toot.reblog ?? timelineIndex.toot - let isStatusTextSensitive: Bool = { - guard let spoilerText = toot.spoilerText, !spoilerText.isEmpty else { return false } - return true - }() - let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: toot.sensitive) + let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute() // append new item into snapshot newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute)) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index 80c86a006..640d9df3b 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -55,7 +55,7 @@ extension HomeTimelineViewModel.LoadLatestState { managedObjectContext.perform { let start = CACurrentMediaTime() - let latestTootIDs: [Toot.ID] + let latestStatusIDs: [Status.ID] let request = HomeTimelineIndex.sortedFetchRequest request.returnsObjectsAsFaults = false request.predicate = predicate @@ -64,10 +64,10 @@ extension HomeTimelineViewModel.LoadLatestState { let timelineIndexes = try managedObjectContext.fetch(request) let endFetch = CACurrentMediaTime() os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect timelineIndexes cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, endFetch - start) - latestTootIDs = timelineIndexes - .prefix(APIService.onceRequestTootMaxCount) // avoid performance issue + latestStatusIDs = timelineIndexes + .prefix(APIService.onceRequestStatusMaxCount) // avoid performance issue .compactMap { timelineIndex in - timelineIndex.value(forKeyPath: #keyPath(HomeTimelineIndex.toot.id)) as? Toot.ID + timelineIndex.value(forKeyPath: #keyPath(HomeTimelineIndex.status.id)) as? Status.ID } } catch { stateMachine.enter(Fail.self) @@ -75,7 +75,7 @@ extension HomeTimelineViewModel.LoadLatestState { } let end = CACurrentMediaTime() - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect toots id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect statuses id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) // TODO: only set large count when using Wi-Fi viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) @@ -86,7 +86,7 @@ extension HomeTimelineViewModel.LoadLatestState { case .failure(let error): // TODO: handle error viewModel.isFetchingLatestTimeline.value = false - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: // handle isFetchingLatestTimeline in fetch controller delegate break @@ -95,15 +95,15 @@ extension HomeTimelineViewModel.LoadLatestState { stateMachine.enter(Idle.self) } receiveValue: { response in - // stop refresher if no new toots - let toots = response.value - let newToots = toots.filter { !latestTootIDs.contains($0.id) } - os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld new toots", ((#file as NSString).lastPathComponent), #line, #function, newToots.count) + // stop refresher if no new statuses + let statuses = response.value + let newStatuses = statuses.filter { !latestStatusIDs.contains($0.id) } + os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld new statuses", ((#file as NSString).lastPathComponent), #line, #function, newStatuses.count) - if newToots.isEmpty { + if newStatuses.isEmpty { viewModel.isFetchingLatestTimeline.value = false } else { - if !latestTootIDs.isEmpty { + if !latestStatusIDs.isEmpty { viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming() } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift index 07b6abf17..b5b9e4ceb 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift @@ -58,12 +58,12 @@ extension HomeTimelineViewModel.LoadMiddleState { stateMachine.enter(Fail.self) return } - let tootIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { timelineIndex in - timelineIndex.toot.id + let statusIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { timelineIndex in + timelineIndex.status.id } // TODO: only set large count when using Wi-Fi - let maxID = timelineIndex.toot.id + let maxID = timelineIndex.status.id viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain,maxID: maxID, authorizationBox: activeMastodonAuthenticationBox) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) @@ -72,16 +72,16 @@ extension HomeTimelineViewModel.LoadMiddleState { switch completion { case .failure(let error): // TODO: handle error - os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) stateMachine.enter(Fail.self) case .finished: break } } receiveValue: { response in - let toots = response.value - let newToots = toots.filter { !tootIDs.contains($0.id) } - os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld toots, %{public}%ld new toots", ((#file as NSString).lastPathComponent), #line, #function, toots.count, newToots.count) - if newToots.isEmpty { + let statuses = response.value + let newStatuses = statuses.filter { !statusIDs.contains($0.id) } + os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld statuses, %{public}%ld new statuses", ((#file as NSString).lastPathComponent), #line, #function, statuses.count, newStatuses.count) + if newStatuses.isEmpty { stateMachine.enter(Fail.self) } else { stateMachine.enter(Success.self) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index 341183dcf..aaabd7a8b 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -53,7 +53,7 @@ extension HomeTimelineViewModel.LoadOldestState { } // TODO: only set large count when using Wi-Fi - let maxID = last.toot.id + let maxID = last.status.id viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, maxID: maxID, authorizationBox: activeMastodonAuthenticationBox) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) @@ -61,15 +61,15 @@ extension HomeTimelineViewModel.LoadOldestState { viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) switch completion { case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: // handle isFetchingLatestTimeline in fetch controller delegate break } } receiveValue: { response in - let toots = response.value - // enter no more state when no new toots - if toots.isEmpty || (toots.count == 1 && toots[0].id == maxID) { + let statuses = response.value + // enter no more state when no new statuses + if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) { stateMachine.enter(NoMore.self) } else { stateMachine.enter(Idle.self) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 7b9c35308..1c0ddf71b 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -74,7 +74,7 @@ final class HomeTimelineViewModel: NSObject { let fetchRequest = HomeTimelineIndex.sortedFetchRequest fetchRequest.fetchBatchSize = 20 fetchRequest.returnsObjectsAsFaults = false - fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(HomeTimelineIndex.toot)] + fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(HomeTimelineIndex.status)] let controller = NSFetchedResultsController( fetchRequest: fetchRequest, managedObjectContext: context.managedObjectContext, diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index 72f62528a..9d609234f 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -63,10 +63,11 @@ class MainTabBarController: UITabBarController { let _viewController = ProfileViewController() _viewController.context = context _viewController.coordinator = coordinator + _viewController.viewModel = MeProfileViewModel(context: context) viewController = _viewController } viewController.title = self.title - return UINavigationController(rootViewController: viewController) + return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController) } } @@ -84,6 +85,11 @@ class MainTabBarController: UITabBarController { extension MainTabBarController { + + open override var childForStatusBarStyle: UIViewController? { + return selectedViewController + } + override func viewDidLoad() { super.viewDidLoad() @@ -100,9 +106,9 @@ extension MainTabBarController { selectedIndex = 0 // TODO: custom accent color - let tabBarAppearance = UITabBarAppearance() - tabBarAppearance.configureWithDefaultBackground() - tabBar.standardAppearance = tabBarAppearance +// let tabBarAppearance = UITabBarAppearance() +// tabBarAppearance.configureWithDefaultBackground() +// tabBar.standardAppearance = tabBarAppearance context.apiService.error .receive(on: DispatchQueue.main) @@ -151,7 +157,7 @@ extension MainTabBarController { .store(in: &disposeBag) #if DEBUG - // selectedIndex = 1 + // selectedIndex = 3 #endif } diff --git a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift index dee1510cd..338be6ab6 100644 --- a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift +++ b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift @@ -66,6 +66,10 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc } extension MastodonConfirmEmailViewController { + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .darkContent + } override func viewDidLoad() { diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 37e8b0dab..a358d5d60 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -56,6 +56,10 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency extension MastodonPickServerViewController { + override var preferredStatusBarStyle: UIStatusBarStyle { + return .darkContent + } + override func viewDidLoad() { super.viewDidLoad() diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 04aea3c19..4151cf3b5 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -221,6 +221,10 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O extension MastodonRegisterViewController { + override var preferredStatusBarStyle: UIStatusBarStyle { + return .darkContent + } + override func viewDidLoad() { super.viewDidLoad() diff --git a/Mastodon/Scene/Onboarding/ResendEmail/MastodonResendEmailViewController.swift b/Mastodon/Scene/Onboarding/ResendEmail/MastodonResendEmailViewController.swift index 25b9ca402..d97209317 100644 --- a/Mastodon/Scene/Onboarding/ResendEmail/MastodonResendEmailViewController.swift +++ b/Mastodon/Scene/Onboarding/ResendEmail/MastodonResendEmailViewController.swift @@ -40,6 +40,11 @@ final class MastodonResendEmailViewController: UIViewController, NeedsDependency } extension MastodonResendEmailViewController { + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .darkContent + } + override func viewDidLoad() { super.viewDidLoad() @@ -59,6 +64,7 @@ extension MastodonResendEmailViewController { webView.load(request) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: resendEmail via: %s", (#file as NSString).lastPathComponent, #line, #function, viewModel.resendEmailURL.debugDescription) } + } extension MastodonResendEmailViewController { diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index 467239b87..e0c6e3605 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -82,6 +82,10 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency extension MastodonServerRulesViewController { + override var preferredStatusBarStyle: UIStatusBarStyle { + return .darkContent + } + override func viewDidLoad() { super.viewDidLoad() diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index e415c5737..838f1327a 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -64,6 +64,10 @@ final class WelcomeViewController: UIViewController, NeedsDependency { extension WelcomeViewController { + override var preferredStatusBarStyle: UIStatusBarStyle { + return .darkContent + } + override func viewDidLoad() { super.viewDidLoad() diff --git a/Mastodon/Scene/Profile/CachedProfileViewModel.swift b/Mastodon/Scene/Profile/CachedProfileViewModel.swift new file mode 100644 index 000000000..0e5823d09 --- /dev/null +++ b/Mastodon/Scene/Profile/CachedProfileViewModel.swift @@ -0,0 +1,17 @@ +// +// CachedProfileViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-31. +// + +import Foundation +import CoreDataStack + +final class CachedProfileViewModel: ProfileViewModel { + + convenience init(context: AppContext, mastodonUser: MastodonUser) { + self.init(context: context, optionalMastodonUser: mastodonUser) + } + +} diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift new file mode 100644 index 000000000..58a7a6110 --- /dev/null +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -0,0 +1,142 @@ +// +// ProfileHeaderViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit + +protocol ProfileHeaderViewControllerDelegate: class { + func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) + func profileHeaderViewController(_ viewController: ProfileHeaderViewController, pageSegmentedControlValueChanged segmentedControl: UISegmentedControl, selectedSegmentIndex index: Int) +} + +final class ProfileHeaderViewController: UIViewController { + + static let segmentedControlHeight: CGFloat = 32 + static let segmentedControlMarginHeight: CGFloat = 20 + static let headerMinHeight: CGFloat = segmentedControlHeight + 2 * segmentedControlMarginHeight + + weak var delegate: ProfileHeaderViewControllerDelegate? + + let profileBannerView = ProfileHeaderView() + let pageSegmentedControl: UISegmentedControl = { + let segmenetedControl = UISegmentedControl(items: ["A", "B"]) + segmenetedControl.selectedSegmentIndex = 0 + return segmenetedControl + }() + + private var isBannerPinned = false + private var bottomShadowAlpha: CGFloat = 0.0 + + private var isAdjustBannerImageViewForSafeAreaInset = false + private var containerSafeAreaInset: UIEdgeInsets = .zero + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ProfileHeaderViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + profileBannerView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(profileBannerView) + NSLayoutConstraint.activate([ + profileBannerView.topAnchor.constraint(equalTo: view.topAnchor), + profileBannerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + profileBannerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + profileBannerView.preservesSuperviewLayoutMargins = true + + pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(pageSegmentedControl) + NSLayoutConstraint.activate([ + pageSegmentedControl.topAnchor.constraint(equalTo: profileBannerView.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), + pageSegmentedControl.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + pageSegmentedControl.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), + view.bottomAnchor.constraint(equalTo: pageSegmentedControl.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), + pageSegmentedControl.heightAnchor.constraint(equalToConstant: ProfileHeaderViewController.segmentedControlHeight).priority(.defaultHigh), + ]) + + pageSegmentedControl.addTarget(self, action: #selector(ProfileHeaderViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if !isAdjustBannerImageViewForSafeAreaInset { + isAdjustBannerImageViewForSafeAreaInset = true + profileBannerView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top + profileBannerView.bannerImageView.frame.size.height += containerSafeAreaInset.top + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + delegate?.profileHeaderViewController(self, viewLayoutDidUpdate: view) + view.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.12), alpha: Float(bottomShadowAlpha), x: 0, y: 2, blur: 2, spread: 0, roundedRect: view.bounds, byRoundingCorners: .allCorners, cornerRadii: .zero) + } + +} + +extension ProfileHeaderViewController { + + @objc private func pageSegmentedControlValueChanged(_ sender: UISegmentedControl) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: selectedSegmentIndex: %ld", ((#file as NSString).lastPathComponent), #line, #function, sender.selectedSegmentIndex) + delegate?.profileHeaderViewController(self, pageSegmentedControlValueChanged: sender, selectedSegmentIndex: sender.selectedSegmentIndex) + } + +} + +extension ProfileHeaderViewController { + + func updateHeaderContainerSafeAreaInset(_ inset: UIEdgeInsets) { + containerSafeAreaInset = inset + } + + private func updateHeaderBottomShadow(progress: CGFloat) { + let alpha = min(max(0, 10 * progress - 9), 1) + if bottomShadowAlpha != alpha { + bottomShadowAlpha = alpha + view.setNeedsLayout() + } + } + + func updateHeaderScrollProgress(_ progress: CGFloat) { + // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) + updateHeaderBottomShadow(progress: progress) + + let bannerImageView = profileBannerView.bannerImageView + guard bannerImageView.bounds != .zero else { + // wait layout finish + return + } + + let bannerContainerInWindow = profileBannerView.convert(profileBannerView.bannerContainerView.frame, to: nil) + let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height + + if bannerContainerInWindow.origin.y > containerSafeAreaInset.top { + bannerImageView.frame.origin.y = -bannerContainerInWindow.origin.y + bannerImageView.frame.size.height = bannerContainerInWindow.origin.y + bannerContainerInWindow.size.height + } else if bannerContainerBottomOffset < containerSafeAreaInset.top { + bannerImageView.frame.origin.y = -containerSafeAreaInset.top + let bannerImageHeight = bannerContainerInWindow.size.height + containerSafeAreaInset.top + (containerSafeAreaInset.top - bannerContainerBottomOffset) + bannerImageView.frame.size.height = bannerImageHeight + } else { + bannerImageView.frame.origin.y = -containerSafeAreaInset.top + bannerImageView.frame.size.height = bannerContainerInWindow.size.height + containerSafeAreaInset.top + } + + // TODO: handle titleView + } + +} diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift b/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift new file mode 100644 index 000000000..e95697e5c --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift @@ -0,0 +1,100 @@ +// +// ProfileFieldView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import UIKit +import ActiveLabel + +final class ProfileFieldView: UIView { + + let titleLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) + label.textColor = Asset.Colors.Label.primary.color + label.text = "Title" + return label + }() + + let valueActiveLabel: ActiveLabel = { + let label = ActiveLabel(style: .profileField) + label.configure(content: "value") + return label + }() + + let topSeparatorLine = UIView.separatorLine + let bottomSeparatorLine = UIView.separatorLine + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileFieldView { + private func _init() { + titleLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(titleLabel) + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: topAnchor), + titleLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor), + titleLabel.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), + ]) + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + + valueActiveLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(valueActiveLabel) + NSLayoutConstraint.activate([ + valueActiveLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + valueActiveLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + valueActiveLabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + ]) + valueActiveLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + topSeparatorLine.translatesAutoresizingMaskIntoConstraints = false + addSubview(topSeparatorLine) + NSLayoutConstraint.activate([ + topSeparatorLine.topAnchor.constraint(equalTo: topAnchor), + topSeparatorLine.leadingAnchor.constraint(equalTo: leadingAnchor), + topSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor), + topSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh), + ]) + + bottomSeparatorLine.translatesAutoresizingMaskIntoConstraints = false + addSubview(bottomSeparatorLine) + NSLayoutConstraint.activate([ + bottomSeparatorLine.bottomAnchor.constraint(equalTo: bottomAnchor), + bottomSeparatorLine.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + bottomSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh), + ]) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ProfileFieldView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + let filedView = ProfileFieldView() + filedView.valueActiveLabel.configure(field: "https://mastodon.online") + return filedView + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift new file mode 100644 index 000000000..286145ffd --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift @@ -0,0 +1,71 @@ +// +// ProfileFriendshipActionButton.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import UIKit + +final class ProfileFriendshipActionButton: RoundedEdgesButton { + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileFriendshipActionButton { + private func _init() { + configure(state: .follow) + } +} + +extension ProfileFriendshipActionButton { + enum State { + case follow + case following + case blocked + case muted + case edit + case editing + + var title: String { + switch self { + case .follow: return L10n.Common.Controls.Firendship.follow + case .following: return L10n.Common.Controls.Firendship.following + case .blocked: return L10n.Common.Controls.Firendship.blocked + case .muted: return L10n.Common.Controls.Firendship.muted + case .edit: return L10n.Common.Controls.Firendship.editInfo + case .editing: return L10n.Common.Controls.Actions.done + } + } + + var backgroundColor: UIColor { + switch self { + case .follow: return Asset.Colors.Button.normal.color + case .following: return Asset.Colors.Button.normal.color + case .blocked: return Asset.Colors.Background.danger.color + case .muted: return Asset.Colors.Background.alertYellow.color + case .edit: return Asset.Colors.Button.normal.color + case .editing: return Asset.Colors.Button.normal.color + } + } + } + + private func configure(state: State) { + setTitle(state.title, for: .normal) + setTitleColor(.white, for: .normal) + setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted) + setBackgroundImage(.placeholder(color: state.backgroundColor), for: .normal) + setBackgroundImage(.placeholder(color: state.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) + setBackgroundImage(.placeholder(color: state.backgroundColor.withAlphaComponent(0.5)), for: .disabled) + } +} + diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift new file mode 100644 index 000000000..7fac52896 --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -0,0 +1,247 @@ +// +// ProfileBannerView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import ActiveLabel + +protocol ProfileHeaderViewDelegate: class { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView) + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dwersDashboardMeterView: ProfileStatusDashboardMeterView) +} + +final class ProfileHeaderView: UIView { + + static let avatarImageViewSize = CGSize(width: 56, height: 56) + static let avatarImageViewCornerRadius: CGFloat = 6 + static let friendshipActionButtonSize = CGSize(width: 108, height: 34) + + weak var delegate: ProfileHeaderViewDelegate? + + let bannerContainerView = UIView() + let bannerImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.image = .placeholder(color: .systemGray) + imageView.layer.masksToBounds = true + return imageView + }() + + let avatarImageView: UIImageView = { + let imageView = UIImageView() + let placeholderImage = UIImage + .placeholder(size: ProfileHeaderView.avatarImageViewSize, color: Asset.Colors.Background.systemGroupedBackground.color) + .af.imageRounded(withCornerRadius: ProfileHeaderView.avatarImageViewCornerRadius, divideRadiusByImageScale: false) + imageView.image = placeholderImage + return imageView + }() + + let nameLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.5 + label.textColor = .white + label.text = "Alice" + label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0) + return label + }() + + let usernameLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.5 + label.textColor = .white + label.text = "@alice" + label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0) + return label + }() + + let statusDashboardView = ProfileStatusDashboardView() + let friendshipActionButton = ProfileFriendshipActionButton() + + let bioContainerView = UIView() + let fieldContainerStackView = UIStackView() + + let bioActiveLabel = ActiveLabel(style: .default) + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileHeaderView { + private func _init() { + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + // banner + bannerContainerView.translatesAutoresizingMaskIntoConstraints = false + bannerContainerView.preservesSuperviewLayoutMargins = true + addSubview(bannerContainerView) + NSLayoutConstraint.activate([ + bannerContainerView.topAnchor.constraint(equalTo: topAnchor), + bannerContainerView.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: bannerContainerView.trailingAnchor), + readableContentGuide.widthAnchor.constraint(equalTo: bannerContainerView.heightAnchor, multiplier: 3), // set height to 1/3 of readable frame width + ]) + + bannerImageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + bannerImageView.frame = bannerContainerView.bounds + bannerContainerView.addSubview(bannerImageView) + + // avatar + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + bannerContainerView.addSubview(avatarImageView) + NSLayoutConstraint.activate([ + avatarImageView.leadingAnchor.constraint(equalTo: bannerContainerView.readableContentGuide.leadingAnchor), + bannerContainerView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 20), + avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1), + avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1), + ]) + + // name container: [display name | username] + let nameContainerStackView = UIStackView() + nameContainerStackView.preservesSuperviewLayoutMargins = true + nameContainerStackView.axis = .vertical + nameContainerStackView.spacing = 0 + nameContainerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(nameContainerStackView) + NSLayoutConstraint.activate([ + nameContainerStackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 12), + nameContainerStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + nameContainerStackView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor), + ]) + nameContainerStackView.addArrangedSubview(nameLabel) + nameContainerStackView.addArrangedSubview(usernameLabel) + + // meta container: [dashboard container | bio container | field container] + let metaContainerStackView = UIStackView() + metaContainerStackView.spacing = 16 + metaContainerStackView.axis = .vertical + metaContainerStackView.preservesSuperviewLayoutMargins = true + metaContainerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(metaContainerStackView) + NSLayoutConstraint.activate([ + metaContainerStackView.topAnchor.constraint(equalTo: bannerContainerView.bottomAnchor, constant: 13), + metaContainerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + metaContainerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + metaContainerStackView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + // dashboard container: [dashboard | friendship action button] + let dashboardContainerView = UIView() + dashboardContainerView.preservesSuperviewLayoutMargins = true + metaContainerStackView.addArrangedSubview(dashboardContainerView) + + statusDashboardView.translatesAutoresizingMaskIntoConstraints = false + dashboardContainerView.addSubview(statusDashboardView) + NSLayoutConstraint.activate([ + statusDashboardView.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor), + statusDashboardView.leadingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.leadingAnchor), + statusDashboardView.bottomAnchor.constraint(equalTo: dashboardContainerView.bottomAnchor), + ]) + + friendshipActionButton.translatesAutoresizingMaskIntoConstraints = false + dashboardContainerView.addSubview(friendshipActionButton) + NSLayoutConstraint.activate([ + friendshipActionButton.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor), + friendshipActionButton.leadingAnchor.constraint(greaterThanOrEqualTo: statusDashboardView.trailingAnchor, constant: 8), + friendshipActionButton.trailingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.trailingAnchor), + friendshipActionButton.widthAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.width).priority(.defaultHigh), + friendshipActionButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.height).priority(.defaultHigh), + ]) + + bioContainerView.preservesSuperviewLayoutMargins = true + metaContainerStackView.addArrangedSubview(bioContainerView) + bioActiveLabel.translatesAutoresizingMaskIntoConstraints = false + bioContainerView.addSubview(bioActiveLabel) + NSLayoutConstraint.activate([ + bioActiveLabel.topAnchor.constraint(equalTo: bioContainerView.topAnchor), + bioActiveLabel.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor), + bioActiveLabel.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor), + bioActiveLabel.bottomAnchor.constraint(equalTo: bioContainerView.bottomAnchor), + ]) + + fieldContainerStackView.preservesSuperviewLayoutMargins = true + metaContainerStackView.addSubview(fieldContainerStackView) + + bringSubviewToFront(bannerContainerView) + bringSubviewToFront(nameContainerStackView) + + bioActiveLabel.delegate = self + } + +} + +// MARK: - ActiveLabelDelegate +extension ProfileHeaderView: ActiveLabelDelegate { + func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select entity: %s", ((#file as NSString).lastPathComponent), #line, #function, entity.primaryText) + delegate?.profileHeaderView(self, activeLabel: activeLabel, entityDidPressed: entity) + } +} + +// MARK: - ProfileStatusDashboardViewDelegate +extension ProfileHeaderView: ProfileStatusDashboardViewDelegate { + + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { + delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, postDashboardMeterViewDidPressed: dashboardMeterView) + } + + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { + delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, followingDashboardMeterViewDidPressed: dashboardMeterView) + } + + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { + delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, followersDashboardMeterViewDidPressed: dashboardMeterView) + } + +} + +// MARK: - AvatarConfigurableView +extension ProfileHeaderView: AvatarConfigurableView { + static var configurableAvatarImageSize: CGSize { avatarImageViewSize } + static var configurableAvatarImageCornerRadius: CGFloat { avatarImageViewCornerRadius } + var configurableAvatarImageView: UIImageView? { return avatarImageView } + var configurableAvatarButton: UIButton? { return nil } +} + + +#if DEBUG +import SwiftUI + +struct ProfileHeaderView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let banner = ProfileHeaderView() + banner.bannerImageView.image = UIImage(named: "lucas-ludwig") + return banner + } + .previewLayout(.fixed(width: 375, height: 800)) + UIViewPreview(width: 375) { + let banner = ProfileHeaderView() + //banner.bannerImageView.image = UIImage(named: "peter-luo") + return banner + } + .preferredColorScheme(.dark) + .previewLayout(.fixed(width: 375, height: 800)) + } + } +} +#endif diff --git a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift new file mode 100644 index 000000000..4355fdc3e --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift @@ -0,0 +1,79 @@ +// +// ProfileStatusDashboardMeterView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import UIKit + +final class ProfileStatusDashboardMeterView: UIView { + + let numberLabel: UILabel = { + let label = UILabel() + label.font = { + let font = UIFont.systemFont(ofSize: 20, weight: .semibold) + return font.fontDescriptor.withDesign(.rounded).flatMap { + UIFont(descriptor: $0, size: 20) + } ?? font + }() + label.textColor = Asset.Colors.Label.primary.color + label.text = "999" + label.textAlignment = .center + return label + }() + + let textLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = Asset.Colors.Label.primary.color + label.text = L10n.Scene.Profile.Dashboard.posts + label.textAlignment = .center + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileStatusDashboardMeterView { + private func _init() { + numberLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(numberLabel) + NSLayoutConstraint.activate([ + numberLabel.topAnchor.constraint(equalTo: topAnchor), + numberLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: numberLabel.trailingAnchor), + ]) + + textLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(textLabel) + NSLayoutConstraint.activate([ + textLabel.topAnchor.constraint(equalTo: numberLabel.bottomAnchor), + textLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: textLabel.trailingAnchor), + bottomAnchor.constraint(equalTo: textLabel.bottomAnchor), + ]) + } +} + +#if DEBUG +import SwiftUI + +struct ProfileStatusDashboardMeterView_Previews: PreviewProvider { + static var previews: some View { + UIViewPreview(width: 54) { + ProfileStatusDashboardMeterView() + } + .previewLayout(.fixed(width: 54, height: 41)) + } +} +#endif diff --git a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift new file mode 100644 index 000000000..4a95fb22f --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift @@ -0,0 +1,103 @@ +// +// ProfileStatusDashboardView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import os.log +import UIKit + +protocol ProfileStatusDashboardViewDelegate: class { + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) +} + +final class ProfileStatusDashboardView: UIView { + + let postDashboardMeterView = ProfileStatusDashboardMeterView() + let followingDashboardMeterView = ProfileStatusDashboardMeterView() + let followersDashboardMeterView = ProfileStatusDashboardMeterView() + + weak var delegate: ProfileStatusDashboardViewDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileStatusDashboardView { + private func _init() { + let containerStackView = UIStackView() + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), + bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), + containerStackView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), + ]) + + let spacing: CGFloat = 16 + containerStackView.spacing = spacing + containerStackView.axis = .horizontal + containerStackView.distribution = .fillEqually + containerStackView.alignment = .top + containerStackView.addArrangedSubview(postDashboardMeterView) + containerStackView.setCustomSpacing(spacing - 2, after: postDashboardMeterView) + containerStackView.addArrangedSubview(followingDashboardMeterView) + containerStackView.setCustomSpacing(spacing + 2, after: followingDashboardMeterView) + containerStackView.addArrangedSubview(followersDashboardMeterView) + + postDashboardMeterView.textLabel.text = L10n.Scene.Profile.Dashboard.posts + followingDashboardMeterView.textLabel.text = L10n.Scene.Profile.Dashboard.following + followersDashboardMeterView.textLabel.text = L10n.Scene.Profile.Dashboard.followers + + [postDashboardMeterView, followingDashboardMeterView, followersDashboardMeterView].forEach { meterView in + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + tapGestureRecognizer.addTarget(self, action: #selector(ProfileStatusDashboardView.tapGestureRecognizerHandler(_:))) + meterView.addGestureRecognizer(tapGestureRecognizer) + } + + } +} + +extension ProfileStatusDashboardView { + @objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard let sourceView = sender.view as? ProfileStatusDashboardMeterView else { + assertionFailure() + return + } + if sourceView === postDashboardMeterView { + delegate?.profileStatusDashboardView(self, postDashboardMeterViewDidPressed: sourceView) + } else if sourceView === followingDashboardMeterView { + delegate?.profileStatusDashboardView(self, followingDashboardMeterViewDidPressed: sourceView) + } else if sourceView === followersDashboardMeterView { + delegate?.profileStatusDashboardView(self, followersDashboardMeterViewDidPressed: sourceView) + } + } +} + + +#if DEBUG +import SwiftUI + +struct ProfileBannerStatusView_Previews: PreviewProvider { + static var previews: some View { + UIViewPreview(width: 375) { + ProfileStatusDashboardView() + } + .previewLayout(.fixed(width: 375, height: 100)) + } +} +#endif diff --git a/Mastodon/Scene/Profile/MeProfileViewModel.swift b/Mastodon/Scene/Profile/MeProfileViewModel.swift new file mode 100644 index 000000000..36bcf0b4e --- /dev/null +++ b/Mastodon/Scene/Profile/MeProfileViewModel.swift @@ -0,0 +1,33 @@ +// +// MeProfileViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class MeProfileViewModel: ProfileViewModel { + + init(context: AppContext) { + super.init( + context: context, + optionalMastodonUser: context.authenticationService.activeMastodonAuthentication.value?.user + ) + + self.currentMastodonUser + .sink { [weak self] currentMastodonUser in + os_log("%{public}s[%{public}ld], %{public}s: current active twitter user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "") + + guard let self = self else { return } + self.mastodonUser.value = currentMastodonUser + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index b3c46a42d..4b74ad632 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -5,20 +5,672 @@ // Created by MainasuK Cirno on 2021-2-23. // +import os.log import UIKit +import Combine +import ActiveLabel final class ProfileViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() + var viewModel: ProfileViewModel! + + private var preferredStatusBarStyleForBanner: UIStatusBarStyle = .lightContent { + didSet { + setNeedsStatusBarAppearanceUpdate() + } + } + + let refreshControl: UIRefreshControl = { + let refreshControl = UIRefreshControl() + refreshControl.tintColor = .label + return refreshControl + }() + + let containerScrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.scrollsToTop = false + scrollView.showsVerticalScrollIndicator = false + scrollView.preservesSuperviewLayoutMargins = true + scrollView.delaysContentTouches = false + return scrollView + }() + + let overlayScrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.showsVerticalScrollIndicator = false + scrollView.backgroundColor = .clear + scrollView.delaysContentTouches = false + return scrollView + }() + + private(set) lazy var profileSegmentedViewController = ProfileSegmentedViewController() + private(set) lazy var profileHeaderViewController = ProfileHeaderViewController() + private var profileBannerImageViewLayoutConstraint: NSLayoutConstraint! + + private var contentOffsets: [Int: CGFloat] = [:] + var currentPostTimelineTableViewContentSizeObservation: NSKeyValueObservation? + + + deinit { + os_log("%{public}s[%{public}ld], %{public}s: deinit", ((#file as NSString).lastPathComponent), #line, #function) + } + } extension ProfileViewController { - override func viewDidLoad() { - super.viewDidLoad() - + func observeTableViewContentSize(scrollView: UIScrollView) -> NSKeyValueObservation { + updateOverlayScrollViewContentSize(scrollView: scrollView) + return scrollView.observe(\.contentSize, options: .new) { scrollView, change in + self.updateOverlayScrollViewContentSize(scrollView: scrollView) + } + } + + func updateOverlayScrollViewContentSize(scrollView: UIScrollView) { + let bottomPageHeight = max(scrollView.contentSize.height, self.containerScrollView.frame.height - ProfileHeaderViewController.headerMinHeight - self.containerScrollView.safeAreaInsets.bottom) + let headerViewHeight: CGFloat = profileHeaderViewController.view.frame.height + let contentSize = CGSize( + width: self.containerScrollView.contentSize.width, + height: bottomPageHeight + headerViewHeight + ) + self.overlayScrollView.contentSize = contentSize + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: contentSize: %s", ((#file as NSString).lastPathComponent), #line, #function, contentSize.debugDescription) } } + +extension ProfileViewController { + + override var preferredStatusBarStyle: UIStatusBarStyle { + return preferredStatusBarStyleForBanner + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + profileHeaderViewController.updateHeaderContainerSafeAreaInset(view.safeAreaInsets) + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + let barAppearance = UINavigationBarAppearance() + barAppearance.configureWithTransparentBackground() + navigationItem.standardAppearance = barAppearance + navigationItem.compactAppearance = barAppearance + navigationItem.scrollEdgeAppearance = barAppearance + navigationItem.titleView = UIView() + +// if navigationController?.viewControllers.first == self { +// navigationItem.leftBarButtonItem = avatarBarButtonItem +// } +// avatarBarButtonItem.avatarButton.addTarget(self, action: #selector(ProfileViewController.avatarButtonPressed(_:)), for: .touchUpInside) + +// unmuteMenuBarButtonItem.target = self +// unmuteMenuBarButtonItem.action = #selector(ProfileViewController.unmuteBarButtonItemPressed(_:)) + +// Publishers.CombineLatest4( +// viewModel.muted.eraseToAnyPublisher(), +// viewModel.blocked.eraseToAnyPublisher(), +// viewModel.twitterUser.eraseToAnyPublisher(), +// context.authenticationService.activeTwitterAuthenticationBox.eraseToAnyPublisher() +// ) +// .receive(on: DispatchQueue.main) +// .sink { [weak self] muted, blocked, twitterUser, activeTwitterAuthenticationBox in +// guard let self = self else { return } +// guard let twitterUser = twitterUser, +// let activeTwitterAuthenticationBox = activeTwitterAuthenticationBox, +// twitterUser.id != activeTwitterAuthenticationBox.twitterUserID else { +// self.navigationItem.rightBarButtonItems = [] +// return +// } +// +// if #available(iOS 14.0, *) { +// self.moreMenuBarButtonItem.target = nil +// self.moreMenuBarButtonItem.action = nil +// self.moreMenuBarButtonItem.menu = UserProviderFacade.createMenuForUser( +// twitterUser: twitterUser, +// muted: muted, +// blocked: blocked, +// dependency: self +// ) +// } else { +// // no menu supports for early version +// self.moreMenuBarButtonItem.target = self +// self.moreMenuBarButtonItem.action = #selector(ProfileViewController.moreMenuBarButtonItemPressed(_:)) +// } +// +// var rightBarButtonItems: [UIBarButtonItem] = [self.moreMenuBarButtonItem] +// if muted { +// rightBarButtonItems.append(self.unmuteMenuBarButtonItem) +// } +// +// self.navigationItem.rightBarButtonItems = rightBarButtonItems +// } +// .store(in: &disposeBag) + + overlayScrollView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged) + +// drawerSidebarTransitionController = DrawerSidebarTransitionController(drawerSidebarTransitionableViewController: self) + + let postsUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter()) + viewModel.domain.assign(to: \.value, on: postsUserTimelineViewModel.domain).store(in: &disposeBag) + viewModel.userID.assign(to: \.value, on: postsUserTimelineViewModel.userID).store(in: &disposeBag) + + let repliesUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: true)) + viewModel.domain.assign(to: \.value, on: repliesUserTimelineViewModel.domain).store(in: &disposeBag) + viewModel.userID.assign(to: \.value, on: repliesUserTimelineViewModel.userID).store(in: &disposeBag) + + let mediaUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(onlyMedia: true)) + viewModel.domain.assign(to: \.value, on: mediaUserTimelineViewModel.domain).store(in: &disposeBag) + viewModel.userID.assign(to: \.value, on: mediaUserTimelineViewModel.userID).store(in: &disposeBag) + + profileSegmentedViewController.pagingViewController.viewModel = { + let profilePagingViewModel = ProfilePagingViewModel( + postsUserTimelineViewModel: postsUserTimelineViewModel, + repliesUserTimelineViewModel: repliesUserTimelineViewModel, + mediaUserTimelineViewModel: mediaUserTimelineViewModel + ) + profilePagingViewModel.viewControllers.forEach { viewController in + if let viewController = viewController as? NeedsDependency { + viewController.context = context + viewController.coordinator = coordinator + } + } + return profilePagingViewModel + }() + + profileHeaderViewController.pageSegmentedControl.removeAllSegments() + profileSegmentedViewController.pagingViewController.viewModel.barItems.forEach { item in + let index = profileHeaderViewController.pageSegmentedControl.numberOfSegments + profileHeaderViewController.pageSegmentedControl.insertSegment(withTitle: item.title, at: index, animated: false) + } + profileHeaderViewController.pageSegmentedControl.selectedSegmentIndex = 0 + + overlayScrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(overlayScrollView) + NSLayoutConstraint.activate([ + overlayScrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), + overlayScrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: overlayScrollView.frameLayoutGuide.trailingAnchor), + view.bottomAnchor.constraint(equalTo: overlayScrollView.frameLayoutGuide.bottomAnchor), + overlayScrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), + ]) + + containerScrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(containerScrollView) + NSLayoutConstraint.activate([ + containerScrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), + containerScrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.trailingAnchor), + view.bottomAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.bottomAnchor), + containerScrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), + ]) + + // add segmented list + addChild(profileSegmentedViewController) + profileSegmentedViewController.view.translatesAutoresizingMaskIntoConstraints = false + containerScrollView.addSubview(profileSegmentedViewController.view) + profileSegmentedViewController.didMove(toParent: self) + NSLayoutConstraint.activate([ + profileSegmentedViewController.view.leadingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.leadingAnchor), + profileSegmentedViewController.view.trailingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.trailingAnchor), + profileSegmentedViewController.view.bottomAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.bottomAnchor), + profileSegmentedViewController.view.heightAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.heightAnchor), + ]) + + // add header + addChild(profileHeaderViewController) + profileHeaderViewController.view.translatesAutoresizingMaskIntoConstraints = false + containerScrollView.addSubview(profileHeaderViewController.view) + profileHeaderViewController.didMove(toParent: self) + NSLayoutConstraint.activate([ + profileHeaderViewController.view.topAnchor.constraint(equalTo: containerScrollView.topAnchor), + profileHeaderViewController.view.leadingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.leadingAnchor), + containerScrollView.contentLayoutGuide.trailingAnchor.constraint(equalTo: profileHeaderViewController.view.trailingAnchor), + profileSegmentedViewController.view.topAnchor.constraint(equalTo: profileHeaderViewController.view.bottomAnchor), + ]) + + containerScrollView.addGestureRecognizer(overlayScrollView.panGestureRecognizer) + overlayScrollView.layer.zPosition = .greatestFiniteMagnitude // make vision top-most + overlayScrollView.delegate = self + profileHeaderViewController.delegate = self + profileSegmentedViewController.pagingViewController.pagingDelegate = self + +// // add segmented bar to header +// profileSegmentedViewController.pagingViewController.addBar( +// bar, +// dataSource: profileSegmentedViewController.pagingViewController.viewModel, +// at: .custom(view: profileHeaderViewController.view, layout: { bar in +// bar.translatesAutoresizingMaskIntoConstraints = false +// self.profileHeaderViewController.view.addSubview(bar) +// NSLayoutConstraint.activate([ +// bar.leadingAnchor.constraint(equalTo: self.profileHeaderViewController.view.leadingAnchor), +// bar.trailingAnchor.constraint(equalTo: self.profileHeaderViewController.view.trailingAnchor), +// bar.bottomAnchor.constraint(equalTo: self.profileHeaderViewController.view.bottomAnchor), +// bar.heightAnchor.constraint(equalToConstant: ProfileHeaderViewController.headerMinHeight).priority(.defaultHigh), +// ]) +// }) +// ) + + // bind view model + Publishers.CombineLatest( + viewModel.bannerImageURL.eraseToAnyPublisher(), + viewModel.viewDidAppear.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] bannerImageURL, _ in + guard let self = self else { return } + self.profileHeaderViewController.profileBannerView.bannerImageView.af.cancelImageRequest() + let placeholder = UIImage.placeholder(color: Asset.Colors.Background.systemGroupedBackground.color) + guard let bannerImageURL = bannerImageURL else { + self.profileHeaderViewController.profileBannerView.bannerImageView.image = placeholder + return + } + self.profileHeaderViewController.profileBannerView.bannerImageView.af.setImage( + withURL: bannerImageURL, + placeholderImage: placeholder, + imageTransition: .crossDissolve(0.3), + runImageTransitionIfCached: false, + completion: { [weak self] response in + guard let self = self else { return } + switch response.result { + case .success(let image): + self.viewModel.headerDomainLumaStyle.value = image.domainLumaCoefficientsStyle ?? .dark + case .failure: + break + } + } + ) + } + .store(in: &disposeBag) + viewModel.headerDomainLumaStyle + .receive(on: DispatchQueue.main) + .sink { [weak self] style in + guard let self = self else { return } + let textColor: UIColor + let shadowColor: UIColor + switch style { + case .light: + self.preferredStatusBarStyleForBanner = .darkContent + textColor = .black + shadowColor = .white + case .dark: + self.preferredStatusBarStyleForBanner = .lightContent + textColor = .white + shadowColor = .black + default: + self.preferredStatusBarStyleForBanner = .default + textColor = .white + shadowColor = .black + } + + self.profileHeaderViewController.profileBannerView.nameLabel.textColor = textColor + self.profileHeaderViewController.profileBannerView.usernameLabel.textColor = textColor + self.profileHeaderViewController.profileBannerView.nameLabel.applyShadow(color: shadowColor, alpha: 0.5, x: 0, y: 2, blur: 2) + self.profileHeaderViewController.profileBannerView.usernameLabel.applyShadow(color: shadowColor, alpha: 0.5, x: 0, y: 2, blur: 2) + } + .store(in: &disposeBag) + Publishers.CombineLatest( + viewModel.avatarImageURL.eraseToAnyPublisher(), + viewModel.viewDidAppear.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] avatarImageURL, _ in + guard let self = self else { return } + self.profileHeaderViewController.profileBannerView.configure( + with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL) + ) + } + .store(in: &disposeBag) +// viewModel.protected +// .map { $0 != true } +// .assign(to: \.isHidden, on: profileHeaderViewController.profileBannerView.lockImageView) +// .store(in: &disposeBag) + viewModel.name + .map { $0 ?? " " } + .receive(on: DispatchQueue.main) + .assign(to: \.text, on: profileHeaderViewController.profileBannerView.nameLabel) + .store(in: &disposeBag) + viewModel.username + .map { username in username.flatMap { "@" + $0 } ?? " " } + .receive(on: DispatchQueue.main) + .assign(to: \.text, on: profileHeaderViewController.profileBannerView.usernameLabel) + .store(in: &disposeBag) +// viewModel.friendship +// .sink { [weak self] friendship in +// guard let self = self else { return } +// let followingButton = self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.followActionButton +// followingButton.isHidden = friendship == nil +// +// if let friendship = friendship { +// switch friendship { +// case .following: followingButton.style = .following +// case .pending: followingButton.style = .pending +// case .none: followingButton.style = .follow +// } +// } +// } +// .store(in: &disposeBag) +// viewModel.followedBy +// .sink { [weak self] followedBy in +// guard let self = self else { return } +// let followStatusLabel = self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.followStatusLabel +// followStatusLabel.isHidden = followedBy != true +// } +// .store(in: &disposeBag) +// + viewModel.bioDescription + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] bio in + guard let self = self else { return } + self.profileHeaderViewController.profileBannerView.bioActiveLabel.configure(note: bio ?? "") + }) + .store(in: &disposeBag) +// Publishers.CombineLatest( +// viewModel.url.eraseToAnyPublisher(), +// viewModel.suspended.eraseToAnyPublisher() +// ) +// .receive(on: DispatchQueue.main) +// .sink { [weak self] url, isSuspended in +// guard let self = self else { return } +// let url = url.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? " " +// self.profileHeaderViewController.profileBannerView.linkButton.setTitle(url, for: .normal) +// let isEmpty = url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty +// self.profileHeaderViewController.profileBannerView.linkContainer.isHidden = isEmpty || isSuspended +// } +// .store(in: &disposeBag) +// Publishers.CombineLatest( +// viewModel.location.eraseToAnyPublisher(), +// viewModel.suspended.eraseToAnyPublisher() +// ) +// .receive(on: DispatchQueue.main) +// .sink { [weak self] location, isSuspended in +// guard let self = self else { return } +// let location = location.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? " " +// self.profileHeaderViewController.profileBannerView.geoButton.setTitle(location, for: .normal) +// let isEmpty = location.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty +// self.profileHeaderViewController.profileBannerView.geoContainer.isHidden = isEmpty || isSuspended +// } +// .store(in: &disposeBag) + viewModel.statusesCount + .sink { [weak self] count in + guard let self = self else { return } + let text = count.flatMap { String($0) } ?? "-" + self.profileHeaderViewController.profileBannerView.statusDashboardView.postDashboardMeterView.numberLabel.text = text + } + .store(in: &disposeBag) + viewModel.followingCount + .sink { [weak self] count in + guard let self = self else { return } + let text = count.flatMap { String($0) } ?? "-" + self.profileHeaderViewController.profileBannerView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text + } + .store(in: &disposeBag) + viewModel.followersCount + .sink { [weak self] count in + guard let self = self else { return } + let text = count.flatMap { String($0) } ?? "-" + self.profileHeaderViewController.profileBannerView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text + } + .store(in: &disposeBag) +// viewModel.followersCount +// .sink { [weak self] count in +// guard let self = self else { return } +// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.followersStatusItemView.countLabel.text = count.flatMap { "\($0)" } ?? "-" +// } +// .store(in: &disposeBag) +// viewModel.listedCount +// .sink { [weak self] count in +// guard let self = self else { return } +// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.listedStatusItemView.countLabel.text = count.flatMap { "\($0)" } ?? "-" +// } +// .store(in: &disposeBag) +// viewModel.suspended +// .receive(on: DispatchQueue.main) +// .sink { [weak self] isSuspended in +// guard let self = self else { return } +// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.isHidden = isSuspended +// self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.isHidden = isSuspended +// if isSuspended { +// self.profileSegmentedViewController +// .pagingViewController.viewModel +// .profileTweetPostTimelineViewController.viewModel +// .stateMachine +// .enter(UserTimelineViewModel.State.Suspended.self) +// self.profileSegmentedViewController +// .pagingViewController.viewModel +// .profileMediaPostTimelineViewController.viewModel +// .stateMachine +// .enter(UserMediaTimelineViewModel.State.Suspended.self) +// self.profileSegmentedViewController +// .pagingViewController.viewModel +// .profileLikesPostTimelineViewController.viewModel +// .stateMachine +// .enter(UserLikeTimelineViewModel.State.Suspended.self) +// } +// } +// .store(in: &disposeBag) + +// + profileHeaderViewController.profileBannerView.delegate = self + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + viewModel.viewDidAppear.send() + + // set overlay scroll view initial content size + guard let currentViewController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer else { return } + currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: currentViewController.scrollView) + currentViewController.scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + currentPostTimelineTableViewContentSizeObservation = nil + } + +} + +extension ProfileViewController { + + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let currentViewController = profileSegmentedViewController.pagingViewController.currentViewController + if let currentViewController = currentViewController as? UserTimelineViewController { + currentViewController.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + sender.endRefreshing() + } + } + +// @objc private func avatarButtonPressed(_ sender: UIButton) { +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) +// coordinator.present(scene: .drawerSidebar, from: self, transition: .custom(transitioningDelegate: drawerSidebarTransitionController)) +// } +// +// @objc private func unmuteBarButtonItemPressed(_ sender: UIBarButtonItem) { +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) +// guard let twitterUser = viewModel.twitterUser.value else { +// assertionFailure() +// return +// } +// +// UserProviderFacade.toggleMuteUser( +// context: context, +// twitterUser: twitterUser, +// muted: viewModel.muted.value +// ) +// .sink { _ in +// // do nothing +// } receiveValue: { _ in +// // do nothing +// } +// .store(in: &disposeBag) +// } +// +// @objc private func moreMenuBarButtonItemPressed(_ sender: UIBarButtonItem) { +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) +// guard let twitterUser = viewModel.twitterUser.value else { +// assertionFailure() +// return +// } +// +// let moreMenuAlertController = UserProviderFacade.createMoreMenuAlertControllerForUser( +// twitterUser: twitterUser, +// muted: viewModel.muted.value, +// blocked: viewModel.blocked.value, +// sender: sender, +// dependency: self +// ) +// present(moreMenuAlertController, animated: true, completion: nil) +// } + +} + +// MARK: - UIScrollViewDelegate +extension ProfileViewController: UIScrollViewDelegate { + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + contentOffsets[profileSegmentedViewController.pagingViewController.currentIndex!] = scrollView.contentOffset.y + let topMaxContentOffsetY = profileSegmentedViewController.view.frame.minY - ProfileHeaderViewController.headerMinHeight - containerScrollView.safeAreaInsets.top + if scrollView.contentOffset.y < topMaxContentOffsetY { + self.containerScrollView.contentOffset.y = scrollView.contentOffset.y + for postTimelineView in profileSegmentedViewController.pagingViewController.viewModel.viewControllers { + postTimelineView.scrollView.contentOffset.y = 0 + } + contentOffsets.removeAll() + } else { + containerScrollView.contentOffset.y = topMaxContentOffsetY + if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer { + let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y + customScrollViewContainerController.scrollView.contentOffset.y = contentOffsetY + } + } + + // elastically banner image + let headerScrollProgress = containerScrollView.contentOffset.y / topMaxContentOffsetY + profileHeaderViewController.updateHeaderScrollProgress(headerScrollProgress) + } + +} + +// MARK: - ProfileHeaderViewControllerDelegate +extension ProfileViewController: ProfileHeaderViewControllerDelegate { + + func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) { + guard let scrollView = (profileSegmentedViewController.pagingViewController.currentViewController as? UserTimelineViewController)?.scrollView else { + // assertionFailure() + return + } + + updateOverlayScrollViewContentSize(scrollView: scrollView) + } + + func profileHeaderViewController(_ viewController: ProfileHeaderViewController, pageSegmentedControlValueChanged segmentedControl: UISegmentedControl, selectedSegmentIndex index: Int) { + profileSegmentedViewController.pagingViewController.scrollToPage( + .at(index: index), + animated: true + ) + } + +} + +// MARK: - ProfilePagingViewControllerDelegate +extension ProfileViewController: ProfilePagingViewControllerDelegate { + + func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController postTimelineViewController: ScrollViewContainer, atIndex index: Int) { + os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, index) + + // save content offset + overlayScrollView.contentOffset.y = contentOffsets[index] ?? containerScrollView.contentOffset.y + + // setup observer and gesture fallback + currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: postTimelineViewController.scrollView) + postTimelineViewController.scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) + + +// if let userMediaTimelineViewController = postTimelineViewController as? UserMediaTimelineViewController, +// let currentState = userMediaTimelineViewController.viewModel.stateMachine.currentState { +// switch currentState { +// case is UserMediaTimelineViewModel.State.NoMore, +// is UserMediaTimelineViewModel.State.NotAuthorized, +// is UserMediaTimelineViewModel.State.Blocked: +// break +// default: +// if userMediaTimelineViewController.viewModel.items.value.isEmpty { +// userMediaTimelineViewController.viewModel.stateMachine.enter(UserMediaTimelineViewModel.State.Reloading.self) +// } +// } +// } +// +// if let userLikeTimelineViewController = postTimelineViewController as? UserLikeTimelineViewController, +// let currentState = userLikeTimelineViewController.viewModel.stateMachine.currentState { +// switch currentState { +// case is UserLikeTimelineViewModel.State.NoMore, +// is UserLikeTimelineViewModel.State.NotAuthorized, +// is UserLikeTimelineViewModel.State.Blocked: +// break +// default: +// if userLikeTimelineViewController.viewModel.items.value.isEmpty { +// userLikeTimelineViewController.viewModel.stateMachine.enter(UserLikeTimelineViewModel.State.Reloading.self) +// } +// } +// } + } + +} + +// MARK: - ProfileBannerInfoActionViewDelegate +//extension ProfileViewController: ProfileBannerInfoActionViewDelegate { +// +// func profileBannerInfoActionView(_ profileBannerInfoActionView: ProfileBannerInfoActionView, followActionButtonPressed button: FollowActionButton) { +// UserProviderFacade +// .toggleUserFriendship(provider: self, sender: button) +// .sink { _ in +// // do nothing +// } receiveValue: { _ in +// // do nothing +// } +// .store(in: &disposeBag) +// } +// +//} + +// MARK: - ProfileHeaderViewDelegate +extension ProfileViewController: ProfileHeaderViewDelegate { + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) { + + } + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { + + } + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView) { + + } + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dwersDashboardMeterView: ProfileStatusDashboardMeterView) { + + } + +} + +// MARK: - ScrollViewContainer +extension ProfileViewController: ScrollViewContainer { + var scrollView: UIScrollView { return overlayScrollView } +} diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift new file mode 100644 index 000000000..f7248009d --- /dev/null +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -0,0 +1,197 @@ +// +// ProfileViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import MastodonSDK + +// please override this base class +class ProfileViewModel: NSObject { + + typealias UserID = String + + var disposeBag = Set() + var observations = Set() + private var mastodonUserObserver: AnyCancellable? + private var currentMastodonUserObserver: AnyCancellable? + + // input + let context: AppContext + let mastodonUser: CurrentValueSubject + let currentMastodonUser = CurrentValueSubject(nil) + let viewDidAppear = PassthroughSubject() + let headerDomainLumaStyle = CurrentValueSubject(.dark) // default dark for placeholder banner + + // output + let domain: CurrentValueSubject + let userID: CurrentValueSubject + let bannerImageURL: CurrentValueSubject + let avatarImageURL: CurrentValueSubject +// let protected: CurrentValueSubject + let name: CurrentValueSubject + let username: CurrentValueSubject + let bioDescription: CurrentValueSubject + let url: CurrentValueSubject + let statusesCount: CurrentValueSubject + let followingCount: CurrentValueSubject + let followersCount: CurrentValueSubject + +// let friendship: CurrentValueSubject +// let followedBy: CurrentValueSubject +// let muted: CurrentValueSubject +// let blocked: CurrentValueSubject +// +// let suspended = CurrentValueSubject(false) +// + + init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) { + self.context = context + self.mastodonUser = CurrentValueSubject(mastodonUser) + self.domain = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value?.domain) + self.userID = CurrentValueSubject(mastodonUser?.id) + self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL()) + self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL()) +// self.protected = CurrentValueSubject(twitterUser?.protected) + self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback) + self.username = CurrentValueSubject(mastodonUser?.acctWithDomain) + self.bioDescription = CurrentValueSubject(mastodonUser?.note) + self.url = CurrentValueSubject(mastodonUser?.url) + self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.statusesCount) }) + self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) }) + self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) }) +// self.friendship = CurrentValueSubject(nil) +// self.followedBy = CurrentValueSubject(nil) +// self.muted = CurrentValueSubject(false) +// self.blocked = CurrentValueSubject(false) + super.init() + + // bind active authentication + context.authenticationService.activeMastodonAuthentication + .sink { [weak self] activeMastodonAuthentication in + guard let self = self else { return } + guard let activeMastodonAuthentication = activeMastodonAuthentication else { + self.domain.value = nil + self.currentMastodonUser.value = nil + return + } + self.domain.value = activeMastodonAuthentication.domain + self.currentMastodonUser.value = activeMastodonAuthentication.user + } + .store(in: &disposeBag) + + setup() + } + +} + +extension ProfileViewModel { + + enum Friendship: CustomDebugStringConvertible { + case following + case pending + case none + + var debugDescription: String { + switch self { + case .following: return "following" + case .pending: return "pending" + case .none: return "none" + } + } + } + +} + +extension ProfileViewModel { + private func setup() { + Publishers.CombineLatest( + mastodonUser.eraseToAnyPublisher(), + currentMastodonUser.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] mastodonUser, currentMastodonUser in + guard let self = self else { return } + self.update(mastodonUser: mastodonUser) + self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + + if let mastodonUser = mastodonUser { + // setup observer + self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser) + .sink { completion in + switch completion { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .finished: + assertionFailure() + } + } receiveValue: { [weak self] change in + guard let self = self else { return } + guard let changeType = change.changeType else { return } + switch changeType { + case .update: + self.update(mastodonUser: mastodonUser) + self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + case .delete: + // TODO: + break + } + } + + } else { + self.mastodonUserObserver = nil + } + + if let currentMastodonUser = currentMastodonUser { + // setup observer + self.currentMastodonUserObserver = ManagedObjectObserver.observe(object: currentMastodonUser) + .sink { completion in + switch completion { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .finished: + assertionFailure() + } + } receiveValue: { [weak self] change in + guard let self = self else { return } + guard let changeType = change.changeType else { return } + switch changeType { + case .update: + self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + case .delete: + // TODO: + break + } + } + } else { + self.currentMastodonUserObserver = nil + } + } + .store(in: &disposeBag) + } + + private func update(mastodonUser: MastodonUser?) { + self.userID.value = mastodonUser?.id + self.bannerImageURL.value = mastodonUser?.headerImageURL() + self.avatarImageURL.value = mastodonUser?.avatarImageURL() +// self.protected.value = twitterUser?.protected + self.name.value = mastodonUser?.displayNameWithFallback + self.username.value = mastodonUser?.acctWithDomain + self.bioDescription.value = mastodonUser?.note + self.url.value = mastodonUser?.url + self.statusesCount.value = mastodonUser.flatMap { Int(truncating: $0.statusesCount) } + self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) } + self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) } + } + + private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) { + // TODO: + } + + +} diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift new file mode 100644 index 000000000..568369d66 --- /dev/null +++ b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift @@ -0,0 +1,47 @@ +// +// ProfilePagingViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import Pageboy +import Tabman + +protocol ProfilePagingViewControllerDelegate: class { + func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController customScrollViewContainerController: ScrollViewContainer, atIndex index: Int) +} + +final class ProfilePagingViewController: TabmanViewController { + + weak var pagingDelegate: ProfilePagingViewControllerDelegate? + var viewModel: ProfilePagingViewModel! + + + // MARK: - PageboyViewControllerDelegate + + override func pageboyViewController(_ pageboyViewController: PageboyViewController, didScrollToPageAt index: TabmanViewController.PageIndex, direction: PageboyViewController.NavigationDirection, animated: Bool) { + super.pageboyViewController(pageboyViewController, didScrollToPageAt: index, direction: direction, animated: animated) + + let viewController = viewModel.viewControllers[index] + pagingDelegate?.profilePagingViewController(self, didScrollToPostCustomScrollViewContainerController: viewController, atIndex: index) + } + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ProfilePagingViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .clear + dataSource = viewModel + } + +} diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift new file mode 100644 index 000000000..252d5e14f --- /dev/null +++ b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift @@ -0,0 +1,68 @@ +// +// ProfilePagingViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import Pageboy +import Tabman + +final class ProfilePagingViewModel: NSObject { + + let postUserTimelineViewController = UserTimelineViewController() + let repliesUserTimelineViewController = UserTimelineViewController() + let mediaUserTimelineViewController = UserTimelineViewController() + + init( + postsUserTimelineViewModel: UserTimelineViewModel, + repliesUserTimelineViewModel: UserTimelineViewModel, + mediaUserTimelineViewModel: UserTimelineViewModel + ) { + postUserTimelineViewController.viewModel = postsUserTimelineViewModel + repliesUserTimelineViewController.viewModel = repliesUserTimelineViewModel + mediaUserTimelineViewController.viewModel = mediaUserTimelineViewModel + super.init() + } + + var viewControllers: [ScrollViewContainer] { + return [ + postUserTimelineViewController, + repliesUserTimelineViewController, + mediaUserTimelineViewController, + ] + } + + let barItems: [TMBarItemable] = { + let items = [ + TMBarItem(title: L10n.Scene.Profile.SegmentedControl.posts), + TMBarItem(title: L10n.Scene.Profile.SegmentedControl.replies), + TMBarItem(title: L10n.Scene.Profile.SegmentedControl.media), + ] + return items + }() + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +// MARK: - PageboyViewControllerDataSource +extension ProfilePagingViewModel: PageboyViewControllerDataSource { + + func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int { + return viewControllers.count + } + + func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? { + return viewControllers[index] + } + + func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? { + return .first + } + +} diff --git a/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift b/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift new file mode 100644 index 000000000..06eaab3f4 --- /dev/null +++ b/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift @@ -0,0 +1,38 @@ +// +// ProfileSegmentedViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit + +final class ProfileSegmentedViewController: UIViewController { + let pagingViewController = ProfilePagingViewController() + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } +} + +extension ProfileSegmentedViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .clear + + addChild(pagingViewController) + pagingViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(pagingViewController.view) + pagingViewController.didMove(toParent: self) + NSLayoutConstraint.activate([ + pagingViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + pagingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: pagingViewController.view.trailingAnchor), + view.bottomAnchor.constraint(equalTo: pagingViewController.view.bottomAnchor), + ]) + } + +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift new file mode 100644 index 000000000..1ea164406 --- /dev/null +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift @@ -0,0 +1,87 @@ +// +// UserTimelineViewController+StatusProvider.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack + +// MARK: - StatusProvider +extension UserTimelineViewController: StatusProvider { + + func status() -> Future { + return Future { promise in promise(.success(nil)) } + } + + func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + return Future { promise in + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + promise(.success(nil)) + return + } + guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + promise(.success(nil)) + return + } + + switch item { + case .status(let objectID, _): + let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext + managedObjectContext.perform { + let status = managedObjectContext.object(with: objectID) as? Status + promise(.success(status)) + } + default: + promise(.success(nil)) + } + } + } + + func status(for cell: UICollectionViewCell) -> Future { + return Future { promise in promise(.success(nil)) } + } + + var managedObjectContext: NSManagedObjectContext { + return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext + } + + var tableViewDiffableDataSource: UITableViewDiffableDataSource? { + return viewModel.diffableDataSource + } + + func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return nil + } + + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + return nil + } + + return item + } + + func items(indexPaths: [IndexPath]) -> [Item] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return [] + } + + var items: [Item] = [] + for indexPath in indexPaths { + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } + items.append(item) + } + return items + } + +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift new file mode 100644 index 000000000..88134f1e1 --- /dev/null +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -0,0 +1,145 @@ +// +// UserTimelineViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import AVKit +import Combine +import CoreDataStack +import GameplayKit + +// TODO: adopt MediaPreviewableViewController +final class UserTimelineViewController: UIViewController, NeedsDependency { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: UserTimelineViewModel! + + // let mediaPreviewTransitionController = MediaPreviewTransitionController() + + lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + return tableView + }() + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension UserTimelineViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tableView.delegate = self + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + statusTableViewCellDelegate: self + ) + + // trigger user timeline loading + Publishers.CombineLatest( + viewModel.domain.removeDuplicates().eraseToAnyPublisher(), + viewModel.userID.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) + } + .store(in: &disposeBag) + + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + context.videoPlaybackService.viewDidDisappear(from: self) + } + +} + +// MARK: - UIScrollViewDelegate +extension UserTimelineViewController { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + handleScrollViewDidScroll(scrollView) + } +} + +// MARK: - UITableViewDelegate +extension UserTimelineViewController: UITableViewDelegate { + + // TODO: cache cell height + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + return 200 + } + +} + +// MARK: - AVPlayerViewControllerDelegate +extension UserTimelineViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + +// MARK: - TimelinePostTableViewCellDelegate +extension UserTimelineViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} + +//// MARK: - TimelineHeaderTableViewCellDelegate +//extension UserTimelineViewController: TimelineHeaderTableViewCellDelegate { } + + +// MARK: - CustomScrollViewContainerController +extension UserTimelineViewController: ScrollViewContainer { + var scrollView: UIScrollView { return tableView } +} + +// MARK: - LoadMoreConfigurableTableViewContainer +extension UserTimelineViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell + typealias LoadingState = UserTimelineViewModel.State.LoadingMore + + var loadMoreConfigurableTableView: UITableView { return tableView } + var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift new file mode 100644 index 000000000..1a09e1b35 --- /dev/null +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift @@ -0,0 +1,37 @@ +// +// UserTimelineViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import UIKit + +extension UserTimelineViewModel { + + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + statusTableViewCellDelegate: StatusTableViewCellDelegate + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = StatusSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher, + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: nil + ) + + // set empty section to make update animation top-to-bottom style + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + } + +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift new file mode 100644 index 000000000..520fa43e5 --- /dev/null +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -0,0 +1,262 @@ +// +// UserTimelineViewModel+State.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension UserTimelineViewModel { + class State: GKState { + weak var viewModel: UserTimelineViewModel? + + init(viewModel: UserTimelineViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension UserTimelineViewModel.State { + class Initial: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + switch stateClass { + case is Reloading.Type: + return viewModel.userID.value != nil + case is Suspended.Type: + return true + default: + return false + } + } + } + + class Reloading: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.Type: + return true + case is NoMore.Type: + return true + case is NotAuthorized.Type, is Blocked.Type: + return true + case is Suspended.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + // reset + viewModel.statusFetchedResultsController.statusIDs.value = [] + + guard let userID = viewModel.userID.value, !userID.isEmpty else { + stateMachine.enter(Fail.self) + return + } + + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + let domain = activeMastodonAuthenticationBox.domain + let queryFilter = viewModel.queryFilter.value + + viewModel.context.apiService.userTimeline( + domain: domain, + accountID: userID, + maxID: nil, + sinceID: nil, + excludeReplies: queryFilter.excludeReplies, + excludeReblogs: queryFilter.excludeReblogs, + onlyMedia: queryFilter.onlyMedia, + authorizationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .sink { completion in + + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + var hasNewStatusesAppend = false + var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value + for status in response.value { + guard !statusIDs.contains(status.id) else { continue } + statusIDs.append(status.id) + hasNewStatusesAppend = true + } + + if hasNewStatusesAppend { + stateMachine.enter(Idle.self) + } else { + stateMachine.enter(NoMore.self) + } + viewModel.statusFetchedResultsController.statusIDs.value = statusIDs + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type, is LoadingMore.Type: + return true + case is Suspended.Type: + return true + default: + return false + } + } + } + + class Idle: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type, is LoadingMore.Type: + return true + case is Suspended.Type: + return true + default: + return false + } + } + } + + class LoadingMore: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.Type: + return true + case is NoMore.Type: + return true + case is NotAuthorized.Type, is Blocked.Type: + return true + case is Suspended.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + guard let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last else { + stateMachine.enter(Fail.self) + return + } + + guard let userID = viewModel.userID.value, !userID.isEmpty else { + stateMachine.enter(Fail.self) + return + } + + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + let domain = activeMastodonAuthenticationBox.domain + let queryFilter = viewModel.queryFilter.value + + viewModel.context.apiService.userTimeline( + domain: domain, + accountID: userID, + maxID: maxID, + sinceID: nil, + excludeReplies: queryFilter.excludeReplies, + excludeReblogs: queryFilter.excludeReblogs, + onlyMedia: queryFilter.onlyMedia, + authorizationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .sink { completion in + + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + var hasNewStatusesAppend = false + var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value + for status in response.value { + guard !statusIDs.contains(status.id) else { continue } + statusIDs.append(status.id) + hasNewStatusesAppend = true + } + + if hasNewStatusesAppend { + stateMachine.enter(Idle.self) + } else { + stateMachine.enter(NoMore.self) + } + viewModel.statusFetchedResultsController.statusIDs.value = statusIDs + } + .store(in: &viewModel.disposeBag) + } + } + + class NotAuthorized: UserTimelineViewModel.State { + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + case is Suspended.Type: + return true + default: + return false + } + } + + } + + class Blocked: UserTimelineViewModel.State { + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + case is Suspended.Type: + return true + default: + return false + } + } + + } + + class Suspended: UserTimelineViewModel.State { + + } + + class NoMore: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + case is NotAuthorized.Type, is Blocked.Type: + return true + case is Suspended.Type: + return true + default: + return false + } + } + } +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift new file mode 100644 index 000000000..de17376d2 --- /dev/null +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -0,0 +1,127 @@ +// +// UserTimelineViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import GameplayKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK +import AlamofireImage + +class UserTimelineViewModel: NSObject { + + var disposeBag = Set() + + // input + let context: AppContext + let domain: CurrentValueSubject + let userID: CurrentValueSubject + let queryFilter: CurrentValueSubject + let statusFetchedResultsController: StatusFetchedResultsController + + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(viewModel: self), + State.Reloading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + State.LoadingMore(viewModel: self), + State.NotAuthorized(viewModel: self), + State.Blocked(viewModel: self), + State.Suspended(viewModel: self), + State.NoMore(viewModel: self), + ]) + stateMachine.enter(State.Initial.self) + return stateMachine + }() + + init(context: AppContext, domain: String?, userID: String?, queryFilter: QueryFilter) { + self.context = context + self.statusFetchedResultsController = StatusFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: domain, + additionalTweetPredicate: Status.notDeleted() + ) + self.domain = CurrentValueSubject(domain) + self.userID = CurrentValueSubject(userID) + self.queryFilter = CurrentValueSubject(queryFilter) + super.init() + + self.domain + .assign(to: \.value, on: statusFetchedResultsController.domain) + .store(in: &disposeBag) + + + statusFetchedResultsController.items + .receive(on: DispatchQueue.main) + .sink { [weak self] objectIDs in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + // var isPermissionDenied = false + + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] + let oldSnapshot = diffableDataSource.snapshot() + for item in oldSnapshot.itemIdentifiers { + guard case let .status(objectID, attribute) = item else { continue } + oldSnapshotAttributeDict[objectID] = attribute + } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + + var items: [Item] = [] + for objectID in objectIDs { + let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute() + items.append(.status(objectID: objectID, attribute: attribute)) + } + snapshot.appendItems(items, toSection: .main) + + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Reloading, is State.LoadingMore, is State.Idle, is State.Fail: + snapshot.appendItems([.bottomLoader], toSection: .main) + // TODO: handle other states + default: + break + } + } + + // not animate when empty items fix loader first appear layout issue + diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty) + } + .store(in: &disposeBag) + } + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension UserTimelineViewModel { + struct QueryFilter { + let excludeReplies: Bool? + let excludeReblogs: Bool? + let onlyMedia: Bool? + + init( + excludeReplies: Bool? = nil, + excludeReblogs: Bool? = nil, + onlyMedia: Bool? = nil + ) { + self.excludeReplies = excludeReplies + self.excludeReblogs = excludeReblogs + self.onlyMedia = onlyMedia + } + } + +} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift index 7d52a5764..a92b8f37e 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift @@ -15,11 +15,11 @@ import MastodonSDK // MARK: - StatusProvider extension PublicTimelineViewController: StatusProvider { - func toot() -> Future { + func status() -> Future { return Future { promise in promise(.success(nil)) } } - func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() @@ -33,11 +33,11 @@ extension PublicTimelineViewController: StatusProvider { } switch item { - case .toot(let objectID, _): + case .status(let objectID, _): let managedObjectContext = self.viewModel.fetchedResultsController.managedObjectContext managedObjectContext.perform { - let toot = managedObjectContext.object(with: objectID) as? Toot - promise(.success(toot)) + let status = managedObjectContext.object(with: objectID) as? Status + promise(.success(status)) } default: promise(.success(nil)) @@ -45,7 +45,7 @@ extension PublicTimelineViewController: StatusProvider { } } - func toot(for cell: UICollectionViewCell) -> Future { + func status(for cell: UICollectionViewCell) -> Future { return Future { promise in promise(.success(nil)) } } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index 8e954c053..844c43cd8 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -159,13 +159,13 @@ extension PublicTimelineViewController: LoadMoreConfigurableTableViewContainer { // MARK: - TimelineMiddleLoaderTableViewCellDelegate extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) { - guard let upperTimelineTootID = upperTimelineTootID else {return} + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) { + guard let upperTimelineStatusID = upperTimelineStatusID else {return} viewModel.loadMiddleSateMachineList .receive(on: DispatchQueue.main) .sink { [weak self] ids in guard let _ = self else { return } - if let stateMachine = ids[upperTimelineTootID] { + if let stateMachine = ids[upperTimelineStatusID] { guard let state = stateMachine.currentState else { assertionFailure() return @@ -185,17 +185,17 @@ extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegat .store(in: &cell.disposeBag) var dict = viewModel.loadMiddleSateMachineList.value - if let _ = dict[upperTimelineTootID] { + if let _ = dict[upperTimelineStatusID] { // do nothing } else { let stateMachine = GKStateMachine(states: [ - PublicTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID), - PublicTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID), - PublicTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID), - PublicTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID), + PublicTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperTimelineStatusID: upperTimelineStatusID), + PublicTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperTimelineStatusID: upperTimelineStatusID), + PublicTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperTimelineStatusID: upperTimelineStatusID), + PublicTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperTimelineStatusID: upperTimelineStatusID), ]) stateMachine.enter(PublicTimelineViewModel.LoadMiddleState.Initial.self) - dict[upperTimelineTootID] = stateMachine + dict[upperTimelineStatusID] = stateMachine viewModel.loadMiddleSateMachineList.value = dict } } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index d69da8f65..ce0e8b19d 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -41,32 +41,32 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) - let indexes = tootIDs.value - let toots = fetchedResultsController.fetchedObjects ?? [] - guard toots.count == indexes.count else { return } - let indexTootTuples: [(Int, Toot)] = toots - .compactMap { toot -> (Int, Toot)? in - guard toot.deletedAt == nil else { return nil } - return indexes.firstIndex(of: toot.id).map { index in (index, toot) } + let indexes = statusIDs.value + let statuses = fetchedResultsController.fetchedObjects ?? [] + guard statuses.count == indexes.count else { return } + let indexStatusTuples: [(Int, Status)] = statuses + .compactMap { status -> (Int, Status)? in + guard status.deletedAt == nil else { return nil } + return indexes.firstIndex(of: status.id).map { index in (index, status) } } .sorted { $0.0 < $1.0 } var oldSnapshotAttributeDict: [NSManagedObjectID: Item.StatusAttribute] = [:] for item in self.items.value { - guard case let .toot(objectID, attribute) = item else { continue } + guard case let .status(objectID, attribute) = item else { continue } oldSnapshotAttributeDict[objectID] = attribute } var items = [Item]() - for (_, toot) in indexTootTuples { - let targetToot = toot.reblog ?? toot + for (_, status) in indexStatusTuples { + let targetStatus = status.reblog ?? status let isStatusTextSensitive: Bool = { - guard let spoilerText = targetToot.spoilerText, !spoilerText.isEmpty else { return false } + guard let spoilerText = targetStatus.spoilerText, !spoilerText.isEmpty else { return false } return true }() - let attribute = oldSnapshotAttributeDict[toot.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetToot.sensitive) - items.append(Item.toot(objectID: toot.objectID, attribute: attribute)) - if tootIDsWhichHasGap.contains(toot.id) { - items.append(Item.publicMiddleLoader(tootID: toot.id)) + let attribute = oldSnapshotAttributeDict[status.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetStatus.sensitive) + items.append(Item.status(objectID: status.objectID, attribute: attribute)) + if statusIDsWhichHasGap.contains(status.id) { + items.append(Item.publicMiddleLoader(statusID: status.id)) } } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift index 62334c746..4727072bf 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift @@ -14,18 +14,18 @@ import os.log extension PublicTimelineViewModel { class LoadMiddleState: GKState { weak var viewModel: PublicTimelineViewModel? - let upperTimelineTootID: String + let upperTimelineStatusID: String - init(viewModel: PublicTimelineViewModel, upperTimelineTootID: String) { + init(viewModel: PublicTimelineViewModel, upperTimelineStatusID: String) { self.viewModel = viewModel - self.upperTimelineTootID = upperTimelineTootID + self.upperTimelineStatusID = upperTimelineStatusID } override func didEnter(from previousState: GKState?) { os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, self.debugDescription, previousState.debugDescription) guard let viewModel = viewModel, let stateMachine = stateMachine else { return } var dict = viewModel.loadMiddleSateMachineList.value - dict[self.upperTimelineTootID] = stateMachine + dict[self.upperTimelineStatusID] = stateMachine viewModel.loadMiddleSateMachineList.value = dict // trigger value change } } @@ -54,42 +54,42 @@ extension PublicTimelineViewModel.LoadMiddleState { } viewModel.context.apiService.publicTimeline( domain: activeMastodonAuthenticationBox.domain, - maxID: upperTimelineTootID + maxID: upperTimelineStatusID ) .receive(on: DispatchQueue.main) .sink { completion in switch completion { case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) stateMachine.enter(Fail.self) case .finished: break } } receiveValue: { response in - let toots = response.value - let addedToots = toots.filter { !viewModel.tootIDs.value.contains($0.id) } + let statuses = response.value + let addedStatuses = statuses.filter { !viewModel.statusIDs.value.contains($0.id) } - guard let gapIndex = viewModel.tootIDs.value.firstIndex(of: self.upperTimelineTootID) else { return } - let upToots = Array(viewModel.tootIDs.value[...gapIndex]) - let downToots = Array(viewModel.tootIDs.value[(gapIndex + 1)...]) + guard let gapIndex = viewModel.statusIDs.value.firstIndex(of: self.upperTimelineStatusID) else { return } + let upStatuses = Array(viewModel.statusIDs.value[...gapIndex]) + let downStatuses = Array(viewModel.statusIDs.value[(gapIndex + 1)...]) - // construct newTootIDs - var newTootIDs = upToots - newTootIDs.append(contentsOf: addedToots.map { $0.id }) - newTootIDs.append(contentsOf: downToots) + // construct newStatusIDs + var newStatusIDs = upStatuses + newStatusIDs.append(contentsOf: addedStatuses.map { $0.id }) + newStatusIDs.append(contentsOf: downStatuses) // remove old gap from viewmodel - if let index = viewModel.tootIDsWhichHasGap.firstIndex(of: self.upperTimelineTootID) { - viewModel.tootIDsWhichHasGap.remove(at: index) + if let index = viewModel.statusIDsWhichHasGap.firstIndex(of: self.upperTimelineStatusID) { + viewModel.statusIDsWhichHasGap.remove(at: index) } // add new gap from viewmodel if need - let intersection = toots.filter { downToots.contains($0.id) } + let intersection = statuses.filter { downStatuses.contains($0.id) } if intersection.isEmpty { - addedToots.last.flatMap { viewModel.tootIDsWhichHasGap.append($0.id) } + addedStatuses.last.flatMap { viewModel.statusIDsWhichHasGap.append($0.id) } } - viewModel.tootIDs.value = newTootIDs - os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld toots, %{public}%ld new toots", (#file as NSString).lastPathComponent, #line, #function, toots.count, addedToots.count) - if addedToots.isEmpty { + viewModel.statusIDs.value = newStatusIDs + os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld statuses, %{public}%ld new statues", (#file as NSString).lastPathComponent, #line, #function, statuses.count, addedStatuses.count) + if addedStatuses.isEmpty { stateMachine.enter(Fail.self) } else { stateMachine.enter(Success.self) diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift index 258db0cdd..c165adb70 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift @@ -68,21 +68,21 @@ extension PublicTimelineViewModel.State { break } } receiveValue: { response in - let resposeTootIDs = response.value.compactMap { $0.id } - var newTootsIDs = resposeTootIDs - let oldTootsIDs = viewModel.tootIDs.value + let resposeStatusIDs = response.value.compactMap { $0.id } + var newStatusIDs = resposeStatusIDs + let oldStatusIDs = viewModel.statusIDs.value var hasGap = true - for tootID in oldTootsIDs { - if !newTootsIDs.contains(tootID) { - newTootsIDs.append(tootID) + for statusID in oldStatusIDs { + if !newStatusIDs.contains(statusID) { + newStatusIDs.append(statusID) } else { hasGap = false } } - if hasGap && oldTootsIDs.count > 0 { - resposeTootIDs.last.flatMap { viewModel.tootIDsWhichHasGap.append($0) } + if hasGap && oldStatusIDs.count > 0 { + resposeStatusIDs.last.flatMap { viewModel.statusIDsWhichHasGap.append($0) } } - viewModel.tootIDs.value = newTootsIDs + viewModel.statusIDs.value = newStatusIDs stateMachine.enter(Idle.self) } .store(in: &viewModel.disposeBag) @@ -138,7 +138,7 @@ extension PublicTimelineViewModel.State { stateMachine.enter(Fail.self) return } - let maxID = viewModel.tootIDs.value.last + let maxID = viewModel.statusIDs.value.last viewModel.context.apiService.publicTimeline( domain: activeMastodonAuthenticationBox.domain, maxID: maxID @@ -153,14 +153,14 @@ extension PublicTimelineViewModel.State { } } receiveValue: { response in stateMachine.enter(Idle.self) - var oldTootsIDs = viewModel.tootIDs.value - for toot in response.value { - if !oldTootsIDs.contains(toot.id) { - oldTootsIDs.append(toot.id) + var oldStatusIDs = viewModel.statusIDs.value + for status in response.value { + if !oldStatusIDs.contains(status.id) { + oldStatusIDs.append(status.id) } } - viewModel.tootIDs.value = oldTootsIDs + viewModel.statusIDs.value = oldStatusIDs } .store(in: &viewModel.disposeBag) } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift index d7d6448a5..c3b1a3d4b 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift @@ -19,7 +19,7 @@ class PublicTimelineViewModel: NSObject { // input let context: AppContext - let fetchedResultsController: NSFetchedResultsController + let fetchedResultsController: NSFetchedResultsController let isFetchingLatestTimeline = CurrentValueSubject(false) @@ -31,7 +31,7 @@ class PublicTimelineViewModel: NSObject { weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? // - var tootIDsWhichHasGap = [String]() + var statusIDsWhichHasGap = [String]() // output var diffableDataSource: UITableViewDiffableDataSource? @@ -47,15 +47,15 @@ class PublicTimelineViewModel: NSObject { return stateMachine }() - let tootIDs = CurrentValueSubject<[String], Never>([]) + let statusIDs = CurrentValueSubject<[String], Never>([]) let items = CurrentValueSubject<[Item], Never>([]) var cellFrameCache = NSCache() init(context: AppContext) { self.context = context self.fetchedResultsController = { - let fetchRequest = Toot.sortedFetchRequest - fetchRequest.predicate = Toot.predicate(domain: "", ids: []) + let fetchRequest = Status.sortedFetchRequest + fetchRequest.predicate = Status.predicate(domain: "", ids: []) fetchRequest.returnsObjectsAsFaults = false fetchRequest.fetchBatchSize = 20 let controller = NSFetchedResultsController( @@ -111,12 +111,12 @@ class PublicTimelineViewModel: NSObject { } .store(in: &disposeBag) - tootIDs + statusIDs .receive(on: DispatchQueue.main) .sink { [weak self] ids in guard let self = self else { return } let domain = self.context.authenticationService.activeMastodonAuthenticationBox.value?.domain ?? "" - self.fetchedResultsController.fetchRequest.predicate = Toot.predicate(domain: domain, ids: ids) + self.fetchedResultsController.fetchRequest.predicate = Status.predicate(domain: domain, ids: ids) do { try self.fetchedResultsController.performFetch() } catch { diff --git a/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift new file mode 100644 index 000000000..8ba7c2257 --- /dev/null +++ b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift @@ -0,0 +1,16 @@ +// +// AdaptiveStatusBarStyleNavigationController.swift +// +// +// Created by MainasuK Cirno on 2021-2-26. +// + +import UIKit + +// Make status bar style adptive for child view controller +// SeeAlso: `modalPresentationCapturesStatusBarAppearance` +final class AdaptiveStatusBarStyleNavigationController: UINavigationController { + override var childForStatusBarStyle: UIViewController? { + return visibleViewController + } +} diff --git a/Mastodon/Scene/Share/NavigationController/DarkContentStatusBarStyleNavigationController.swift b/Mastodon/Scene/Share/NavigationController/DarkContentStatusBarStyleNavigationController.swift deleted file mode 100644 index 0fa4a0e20..000000000 --- a/Mastodon/Scene/Share/NavigationController/DarkContentStatusBarStyleNavigationController.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// DarkContentStatusBarStyleNavigationController.swift -// -// -// Created by MainasuK Cirno on 2021-2-26. -// - -import UIKit - -final class DarkContentStatusBarStyleNavigationController: UINavigationController { - override var preferredStatusBarStyle: UIStatusBarStyle { - return .darkContent - } -} diff --git a/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift b/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift index a38b711dd..266fa6594 100644 --- a/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift +++ b/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift @@ -7,7 +7,7 @@ import UIKit -final class RoundedEdgesButton: UIButton { +class RoundedEdgesButton: UIButton { override func layoutSubviews() { super.layoutSubviews() diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 6d7800b04..b4105a4d2 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -12,6 +12,8 @@ import ActiveLabel import AlamofireImage protocol StatusViewDelegate: class { + func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) + func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) @@ -195,8 +197,9 @@ final class StatusView: UIView { return actionToolbarContainer }() - let activeTextLabel = ActiveLabel(style: .default) + + private let headerInfoLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer override init(frame: CGRect) { super.init(frame: frame) @@ -226,7 +229,7 @@ final class StatusView: UIView { extension StatusView { func _init() { - // container: [retoot | author | status | action toolbar] + // container: [reblog | author | status | action toolbar] let containerStackView = UIStackView() containerStackView.axis = .vertical containerStackView.spacing = 10 @@ -401,6 +404,12 @@ extension StatusView { playerContainerView.delegate = self + headerInfoLabelTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerInfoLabelTapGestureRecognizerHandler(_:))) + headerInfoLabel.isUserInteractionEnabled = true + headerInfoLabel.addGestureRecognizer(headerInfoLabelTapGestureRecognizer) + + avatarButton.addTarget(self, action: #selector(StatusView.avatarButtonDidPressed(_:)), for: .touchUpInside) + avatarStackedContainerButton.addTarget(self, action: #selector(StatusView.avatarStackedContainerButtonDidPressed(_:)), for: .touchUpInside) contentWarningActionButton.addTarget(self, action: #selector(StatusView.contentWarningActionButtonPressed(_:)), for: .touchUpInside) pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside) } @@ -439,6 +448,21 @@ extension StatusView { extension StatusView { + @objc private func headerInfoLabelTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.statusView(self, headerInfoLabelDidPressed: headerInfoLabel) + } + + @objc private func avatarButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.statusView(self, avatarButtonDidPressed: sender) + } + + @objc private func avatarStackedContainerButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.statusView(self, avatarButtonDidPressed: sender) + } + @objc private func contentWarningActionButtonPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) delegate?.statusView(self, contentWarningActionButtonPressed: sender) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 5f9bdf654..9c954e505 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -20,6 +20,8 @@ protocol StatusTableViewCellDelegate: class { var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { get } func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController) + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel) + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) @@ -194,6 +196,14 @@ extension StatusTableViewCell: UITableViewDelegate { // MARK: - StatusViewDelegate extension StatusTableViewCell: StatusViewDelegate { + func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) { + delegate?.statusTableViewCell(self, statusView: statusView, headerInfoLabelDidPressed: label) + } + + func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) { + delegate?.statusTableViewCell(self, statusView: statusView, avatarButtonDidPressed: button) + } + func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) { delegate?.statusTableViewCell(self, statusView: statusView, contentWarningActionButtonPressed: button) } diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift index 597420481..75c06a339 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift @@ -11,7 +11,7 @@ import os.log import UIKit protocol TimelineMiddleLoaderTableViewCellDelegate: class { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID:NSManagedObjectID?) + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID:NSManagedObjectID?) func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) } diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index 2eacff573..d49a7371b 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -17,29 +17,29 @@ extension APIService { // make local state change only func like( - tootObjectID: NSManagedObjectID, + statusObjectID: NSManagedObjectID, mastodonUserObjectID: NSManagedObjectID, favoriteKind: Mastodon.API.Favorites.FavoriteKind - ) -> AnyPublisher { - var _targetTootID: Toot.ID? + ) -> AnyPublisher { + var _targetStatusID: Status.ID? let managedObjectContext = backgroundManagedObjectContext return managedObjectContext.performChanges { - let toot = managedObjectContext.object(with: tootObjectID) as! Toot + let status = managedObjectContext.object(with: statusObjectID) as! Status let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser - let targetToot = toot.reblog ?? toot - let targetTootID = targetToot.id - _targetTootID = targetTootID + let targetStatus = status.reblog ?? status + let targetStatusID = targetStatus.id + _targetStatusID = targetStatusID - targetToot.update(liked: favoriteKind == .create, mastodonUser: mastodonUser) + targetStatus.update(liked: favoriteKind == .create, by: mastodonUser) } .tryMap { result in switch result { case .success: - guard let targetTootID = _targetTootID else { + guard let targetStatusID = _targetStatusID else { throw APIError.implicit(.badRequest) } - return targetTootID + return targetStatusID case .failure(let error): assertionFailure(error.localizedDescription) @@ -76,12 +76,12 @@ extension APIService { return nil } }() - let _oldToot: Toot? = { - let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(domain: mastodonAuthenticationBox.domain, id: statusID) + let _oldStatus: Status? = { + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: mastodonAuthenticationBox.domain, id: statusID) request.fetchLimit = 1 request.returnsObjectsAsFaults = false - request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] + request.relationshipKeyPathsForPrefetching = [#keyPath(Status.reblog)] do { return try managedObjectContext.fetch(request).first } catch { @@ -91,15 +91,15 @@ extension APIService { }() guard let requestMastodonUser = _requestMastodonUser, - let oldToot = _oldToot else { + let oldStatus = _oldStatus else { assertionFailure() return } - APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) + APIService.CoreData.merge(status: oldStatus, entity: entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) if favoriteKind == .destroy { - oldToot.update(favouritesCount: NSNumber(value: max(0, oldToot.favouritesCount.intValue - 1))) + oldStatus.update(favouritesCount: NSNumber(value: max(0, oldStatus.favouritesCount.intValue - 1))) } - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s like status to: %{public}s. now %ld likes", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.favourited.flatMap { $0 ? "like" : "unlike" } ?? "", entity.favouritesCount ) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update status %{public}s like status to: %{public}s. now %ld likes", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.favourited.flatMap { $0 ? "like" : "unlike" } ?? "", entity.favouritesCount ) } .setFailureType(to: Error.self) .tryMap { result -> Mastodon.Response.Content in @@ -129,7 +129,7 @@ extension APIService { extension APIService { func likeList( - limit: Int = onceRequestTootMaxCount, + limit: Int = onceRequestStatusMaxCount, userID: String, maxID: String? = nil, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox diff --git a/Mastodon/Service/APIService/APIService+HomeTimeline.swift b/Mastodon/Service/APIService/APIService+HomeTimeline.swift index 2fbb45e0b..d4cbe69c2 100644 --- a/Mastodon/Service/APIService/APIService+HomeTimeline.swift +++ b/Mastodon/Service/APIService/APIService+HomeTimeline.swift @@ -19,7 +19,7 @@ extension APIService { domain: String, sinceID: Mastodon.Entity.Status.ID? = nil, maxID: Mastodon.Entity.Status.ID? = nil, - limit: Int = onceRequestTootMaxCount, + limit: Int = onceRequestStatusMaxCount, local: Bool? = nil, authorizationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { diff --git a/Mastodon/Service/APIService/APIService+PublicTimeline.swift b/Mastodon/Service/APIService/APIService+PublicTimeline.swift index cd02526d6..bd176f311 100644 --- a/Mastodon/Service/APIService/APIService+PublicTimeline.swift +++ b/Mastodon/Service/APIService/APIService+PublicTimeline.swift @@ -21,7 +21,7 @@ extension APIService { domain: String, sinceID: Mastodon.Entity.Status.ID? = nil, maxID: Mastodon.Entity.Status.ID? = nil, - limit: Int = onceRequestTootMaxCount + limit: Int = onceRequestStatusMaxCount ) -> AnyPublisher, Error> { let query = Mastodon.API.Timeline.PublicTimelineQuery( local: nil, diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index 92ff85c10..6dc9f189b 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -16,34 +16,34 @@ extension APIService { // make local state change only func reblog( - tootObjectID: NSManagedObjectID, + statusObjectID: NSManagedObjectID, mastodonUserObjectID: NSManagedObjectID, reblogKind: Mastodon.API.Reblog.ReblogKind - ) -> AnyPublisher { - var _targetTootID: Toot.ID? + ) -> AnyPublisher { + var _targetStatusID: Status.ID? let managedObjectContext = backgroundManagedObjectContext return managedObjectContext.performChanges { - let toot = managedObjectContext.object(with: tootObjectID) as! Toot + let status = managedObjectContext.object(with: statusObjectID) as! Status let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser - let targetToot = toot.reblog ?? toot - let targetTootID = targetToot.id - _targetTootID = targetTootID + let targetStatus = status.reblog ?? status + let targetStatusID = targetStatus.id + _targetStatusID = targetStatusID switch reblogKind { case .reblog: - targetToot.update(reblogged: true, mastodonUser: mastodonUser) + targetStatus.update(reblogged: true, by: mastodonUser) case .undoReblog: - targetToot.update(reblogged: false, mastodonUser: mastodonUser) + targetStatus.update(reblogged: false, by: mastodonUser) } } .tryMap { result in switch result { case .success: - guard let targetTootID = _targetTootID else { + guard let targetStatusID = _targetStatusID else { throw APIError.implicit(.badRequest) } - return targetTootID + return targetStatusID case .failure(let error): assertionFailure(error.localizedDescription) @@ -85,25 +85,25 @@ extension APIService { return } - guard let oldToot: Toot = { - let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(domain: domain, id: statusID) + guard let oldStatus: Status = { + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: domain, id: statusID) request.fetchLimit = 1 request.returnsObjectsAsFaults = false - request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] + request.relationshipKeyPathsForPrefetching = [#keyPath(Status.reblog)] return managedObjectContext.safeFetch(request).first }() else { return } - APIService.CoreData.merge(toot: oldToot, entity: entity.reblog ?? entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) + APIService.CoreData.merge(status: oldStatus, entity: entity.reblog ?? entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) switch reblogKind { case .undoReblog: - oldToot.update(reblogsCount: NSNumber(value: max(0, oldToot.reblogsCount.intValue - 1))) + oldStatus.update(reblogsCount: NSNumber(value: max(0, oldStatus.reblogsCount.intValue - 1))) default: break } - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s reblog status to: %{public}s. now %ld reblog", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.reblogged.flatMap { $0 ? "reblog" : "unreblog" } ?? "", entity.reblogsCount ) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update status %{public}s reblog status to: %{public}s. now %ld reblog", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.reblogged.flatMap { $0 ? "reblog" : "unreblog" } ?? "", entity.reblogsCount ) } .setFailureType(to: Error.self) .tryMap { result -> Mastodon.Response.Content in diff --git a/Mastodon/Service/APIService/APIService+Relationship.swift b/Mastodon/Service/APIService/APIService+Relationship.swift new file mode 100644 index 000000000..7ad5b4745 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Relationship.swift @@ -0,0 +1,65 @@ +// +// APIService+Relationship.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func relationship( + domain: String, + accountIDs: [Mastodon.Entity.Account.ID], + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + fatalError() +// let authorization = authorizationBox.userAuthorization +// let requestMastodonUserID = authorizationBox.userID +// let query = Mastodon.API.Account.AccountStatuseseQuery( +// maxID: maxID, +// sinceID: sinceID, +// excludeReplies: excludeReplies, +// excludeReblogs: excludeReblogs, +// onlyMedia: onlyMedia, +// limit: limit +// ) +// +// return Mastodon.API.Account.statuses( +// session: session, +// domain: domain, +// accountID: accountID, +// query: query, +// authorization: authorization +// ) +// .flatMap { response -> AnyPublisher, Error> in +// return APIService.Persist.persistStatus( +// managedObjectContext: self.backgroundManagedObjectContext, +// domain: domain, +// query: nil, +// response: response, +// persistType: .user, +// requestMastodonUserID: requestMastodonUserID, +// log: OSLog.api +// ) +// .setFailureType(to: Error.self) +// .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in +// switch result { +// case .success: +// return response +// case .failure(let error): +// throw error +// } +// } +// .eraseToAnyPublisher() +// } +// .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/APIService+UserTimeline.swift b/Mastodon/Service/APIService/APIService+UserTimeline.swift new file mode 100644 index 000000000..cb20c85ef --- /dev/null +++ b/Mastodon/Service/APIService/APIService+UserTimeline.swift @@ -0,0 +1,70 @@ +// +// APIService+UserTimeline.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func userTimeline( + domain: String, + accountID: String, + maxID: Mastodon.Entity.Status.ID? = nil, + sinceID: Mastodon.Entity.Status.ID? = nil, + limit: Int = onceRequestStatusMaxCount, + excludeReplies: Bool? = nil, + excludeReblogs: Bool? = nil, + onlyMedia: Bool? = nil, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + let requestMastodonUserID = authorizationBox.userID + let query = Mastodon.API.Account.AccountStatuseseQuery( + maxID: maxID, + sinceID: sinceID, + excludeReplies: excludeReplies, + excludeReblogs: excludeReblogs, + onlyMedia: onlyMedia, + limit: limit + ) + + return Mastodon.API.Account.statuses( + session: session, + domain: domain, + accountID: accountID, + query: query, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return APIService.Persist.persistStatus( + managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: nil, + response: response, + persistType: .user, + requestMastodonUserID: requestMastodonUserID, + log: OSLog.api + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/APIService.swift b/Mastodon/Service/APIService/APIService.swift index 655684cc5..11e6f5cac 100644 --- a/Mastodon/Service/APIService/APIService.swift +++ b/Mastodon/Service/APIService/APIService.swift @@ -44,7 +44,7 @@ final class APIService { } extension APIService { - public static let onceRequestTootMaxCount = 100 + public static let onceRequestStatusMaxCount = 100 public static let onceRequestUserMaxCount = 100 } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift index 512e224d2..745f47999 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift @@ -48,11 +48,11 @@ extension APIService.CoreData { if let oldMastodonUser = oldMastodonUser { // merge old mastodon usre - APIService.CoreData.mergeMastodonUser( - for: requestMastodonUser, - old: oldMastodonUser, - in: domain, + APIService.CoreData.merge( + user: oldMastodonUser, entity: entity, + requestMastodonUser: requestMastodonUser, + domain: domain, networkDate: networkDate ) return (oldMastodonUser, false) @@ -68,11 +68,15 @@ extension APIService.CoreData { } } - static func mergeMastodonUser( - for requestMastodonUser: MastodonUser?, - old user: MastodonUser, - in domain: String, +} + +extension APIService.CoreData { + + static func merge( + user: MastodonUser, entity: Mastodon.Entity.Account, + requestMastodonUser: MastodonUser?, + domain: String, networkDate: Date ) { guard networkDate > user.updatedAt else { return } @@ -84,6 +88,38 @@ extension APIService.CoreData { user.update(displayName: property.displayName) user.update(avatar: property.avatar) user.update(avatarStatic: property.avatarStatic) + user.update(header: property.header) + user.update(headerStatic: property.headerStatic) + user.update(note: property.note) + user.update(url: property.url) + user.update(statusesCount: property.statusesCount) + user.update(followingCount: property.followingCount) + user.update(followersCount: property.followersCount) + + user.didUpdate(at: networkDate) + } + +} + +extension APIService.CoreData { + + static func update( + user: MastodonUser, + entity: Mastodon.Entity.Relationship, + requestMastodonUser: MastodonUser, + domain: String, + networkDate: Date + ) { + guard networkDate > user.updatedAt else { return } + + user.update(isFollowing: entity.following, by: requestMastodonUser) + entity.requested.flatMap { user.update(isFollowRequested: $0, by: requestMastodonUser) } + entity.endorsed.flatMap { user.update(isEndorsed: $0, by: requestMastodonUser) } + requestMastodonUser.update(isFollowing: entity.followedBy, by: user) + entity.muting.flatMap { user.update(isMuting: $0, by: requestMastodonUser) } + user.update(isBlocking: entity.blocking, by: requestMastodonUser) + entity.domainBlocking.flatMap { user.update(isDomainBlocking: $0, by: requestMastodonUser) } + entity.blockedBy.flatMap { requestMastodonUser.update(isBlocking: $0, by: user) } user.didUpdate(at: networkDate) } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift index 28bfd9727..a05574b6b 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift @@ -18,39 +18,39 @@ extension APIService.CoreData { for requestMastodonUser: MastodonUser?, domain: String, entity: Mastodon.Entity.Status, - tootCache: APIService.Persist.PersistCache?, + statusCache: APIService.Persist.PersistCache?, userCache: APIService.Persist.PersistCache?, networkDate: Date, log: OSLog - ) -> (Toot: Toot, isTootCreated: Bool, isMastodonUserCreated: Bool) { + ) -> (status: Status, isStatusCreated: Bool, isMastodonUserCreated: Bool) { let processEntityTaskSignpostID = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id) + os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "process status %{public}s", entity.id) defer { - os_signpost(.end, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id) + os_signpost(.end, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "process status %{public}s", entity.id) } // build tree - let reblog = entity.reblog.flatMap { entity -> Toot in - let (toot, _, _) = createOrMergeStatus( + let reblog = entity.reblog.flatMap { entity -> Status in + let (status, _, _) = createOrMergeStatus( into: managedObjectContext, for: requestMastodonUser, domain: domain, entity: entity, - tootCache: tootCache, + statusCache: statusCache, userCache: userCache, networkDate: networkDate, log: log ) - return toot + return status } - // fetch old Toot - let oldToot: Toot? = { - if let tootCache = tootCache { - return tootCache.dictionary[entity.id] + // fetch old Status + let oldStatus: Status? = { + if let statusCache = statusCache { + return statusCache.dictionary[entity.id] } else { - let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(domain: domain, id: entity.id) + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: domain, id: entity.id) request.fetchLimit = 1 request.returnsObjectsAsFaults = false do { @@ -62,19 +62,19 @@ extension APIService.CoreData { } }() - if let oldToot = oldToot { - // merge old Toot - APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) - return (oldToot, false, false) + if let oldStatus = oldStatus { + // merge old Status + APIService.CoreData.merge(status: oldStatus, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) + return (oldStatus, false, false) } else { let (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, userCache: userCache, networkDate: networkDate, log: log) let application = entity.application.flatMap { app -> Application? in Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey)) } - let replyTo: Toot? = { - // could be nil if target replyTo toot's persist task in the queue + let replyTo: Status? = { + // could be nil if target replyTo status's persist task in the queue guard let inReplyToID = entity.inReplyToID, - let replyTo = tootCache?.dictionary[inReplyToID] else { return nil } + let replyTo = statusCache?.dictionary[inReplyToID] else { return nil } return replyTo }() let poll = entity.poll.flatMap { poll -> Poll in @@ -111,10 +111,10 @@ extension APIService.CoreData { guard !attachments.isEmpty else { return nil } return attachments }() - let tootProperty = Toot.Property(entity: entity, domain: domain, networkDate: networkDate) - let toot = Toot.insert( + let statusProperty = Status.Property(entity: entity, domain: domain, networkDate: networkDate) + let status = Status.insert( into: managedObjectContext, - property: tootProperty, + property: statusProperty, author: mastodonUser, reblog: reblog, application: application, @@ -130,67 +130,81 @@ extension APIService.CoreData { bookmarkedBy: (entity.bookmarked ?? false) ? requestMastodonUser : nil, pinnedBy: (entity.pinned ?? false) ? requestMastodonUser : nil ) - tootCache?.dictionary[entity.id] = toot - os_signpost(.event, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "did insert new tweet %{public}s: %s", mastodonUser.identifier, entity.id) - return (toot, true, isMastodonUserCreated) + statusCache?.dictionary[entity.id] = status + os_signpost(.event, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "did insert new tweet %{public}s: %s", mastodonUser.identifier, entity.id) + return (status, true, isMastodonUserCreated) } } +} + +extension APIService.CoreData { static func merge( - toot: Toot, + status: Status, entity: Mastodon.Entity.Status, requestMastodonUser: MastodonUser?, domain: String, networkDate: Date ) { - guard networkDate > toot.updatedAt else { return } + guard networkDate > status.updatedAt else { return } // merge poll - if let poll = toot.poll, let entity = entity.poll { + if let poll = status.poll, let entity = entity.poll { merge(poll: poll, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) } // merge metrics - if entity.favouritesCount != toot.favouritesCount.intValue { - toot.update(favouritesCount:NSNumber(value: entity.favouritesCount)) + if entity.favouritesCount != status.favouritesCount.intValue { + status.update(favouritesCount:NSNumber(value: entity.favouritesCount)) } if let repliesCount = entity.repliesCount { - if (repliesCount != toot.repliesCount?.intValue) { - toot.update(repliesCount:NSNumber(value: repliesCount)) + if (repliesCount != status.repliesCount?.intValue) { + status.update(repliesCount:NSNumber(value: repliesCount)) } } - if entity.reblogsCount != toot.reblogsCount.intValue { - toot.update(reblogsCount:NSNumber(value: entity.reblogsCount)) + if entity.reblogsCount != status.reblogsCount.intValue { + status.update(reblogsCount:NSNumber(value: entity.reblogsCount)) } // merge relationship if let mastodonUser = requestMastodonUser { if let favourited = entity.favourited { - toot.update(liked: favourited, mastodonUser: mastodonUser) + status.update(liked: favourited, by: mastodonUser) } if let reblogged = entity.reblogged { - toot.update(reblogged: reblogged, mastodonUser: mastodonUser) + status.update(reblogged: reblogged, by: mastodonUser) } if let muted = entity.muted { - toot.update(muted: muted, mastodonUser: mastodonUser) + status.update(muted: muted, by: mastodonUser) } if let bookmarked = entity.bookmarked { - toot.update(bookmarked: bookmarked, mastodonUser: mastodonUser) + status.update(bookmarked: bookmarked, by: mastodonUser) } } // set updateAt - toot.didUpdate(at: networkDate) + status.didUpdate(at: networkDate) // merge user - mergeMastodonUser(for: requestMastodonUser, old: toot.author, in: domain, entity: entity.account, networkDate: networkDate) + merge( + user: status.author, + entity: entity.account, + requestMastodonUser: requestMastodonUser, + domain: domain, + networkDate: networkDate + ) // merge indirect reblog - if let reblog = toot.reblog, let reblogEntity = entity.reblog { - merge(toot: reblog, entity: reblogEntity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) + if let reblog = status.reblog, let reblogEntity = entity.reblog { + merge( + status: reblog, + entity: reblogEntity, + requestMastodonUser: requestMastodonUser, + domain: domain, + networkDate: networkDate + ) } } - } extension APIService.CoreData { diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift index 16461494a..eb354035f 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift @@ -17,23 +17,23 @@ extension APIService.Persist { } -extension APIService.Persist.PersistCache where T == Toot { +extension APIService.Persist.PersistCache where T == Status { - static func ids(for toots: [Mastodon.Entity.Status]) -> Set { + static func ids(for statuses: [Mastodon.Entity.Status]) -> Set { var value = Set() - for toot in toots { - value = value.union(ids(for: toot)) + for status in statuses { + value = value.union(ids(for: status)) } return value } - static func ids(for toot: Mastodon.Entity.Status) -> Set { + static func ids(for status: Mastodon.Entity.Status) -> Set { var value = Set() - value.insert(toot.id) - if let inReplyToID = toot.inReplyToID { + value.insert(status.id) + if let inReplyToID = status.inReplyToID { value.insert(inReplyToID) } - if let reblog = toot.reblog { + if let reblog = status.reblog { value = value.union(ids(for: reblog)) } return value @@ -43,21 +43,21 @@ extension APIService.Persist.PersistCache where T == Toot { extension APIService.Persist.PersistCache where T == MastodonUser { - static func ids(for toots: [Mastodon.Entity.Status]) -> Set { + static func ids(for statuses: [Mastodon.Entity.Status]) -> Set { var value = Set() - for toot in toots { - value = value.union(ids(for: toot)) + for status in statuses { + value = value.union(ids(for: status)) } return value } - static func ids(for toot: Mastodon.Entity.Status) -> Set { + static func ids(for status: Mastodon.Entity.Status) -> Set { var value = Set() - value.insert(toot.account.id) - if let inReplyToAccountID = toot.inReplyToAccountID { + value.insert(status.account.id) + if let inReplyToAccountID = status.inReplyToAccountID { value.insert(inReplyToAccountID) } - if let reblog = toot.reblog { + if let reblog = status.reblog { value = value.union(ids(for: reblog)) } return value diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift index b74bb7771..dab4ba6ad 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift @@ -125,36 +125,36 @@ extension APIService.Persist.PersistMemo { } -extension APIService.Persist.PersistMemo where T == Toot, U == MastodonUser { +extension APIService.Persist.PersistMemo where T == Status, U == MastodonUser { - static func createOrMergeToot( + static func createOrMergeStatus( into managedObjectContext: NSManagedObjectContext, for requestMastodonUser: MastodonUser?, requestMastodonUserID: MastodonUser.ID?, domain: String, entity: Mastodon.Entity.Status, memoType: MemoType, - tootCache: APIService.Persist.PersistCache?, + statusCache: APIService.Persist.PersistCache?, userCache: APIService.Persist.PersistCache?, networkDate: Date, log: OSLog ) -> APIService.Persist.PersistMemo { let processEntityTaskSignpostID = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id) + os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "process status %{public}s", entity.id) defer { - os_signpost(.end, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "finish process toot %{public}s", entity.id) + os_signpost(.end, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "finish process status %{public}s", entity.id) } // build tree let reblogMemo = entity.reblog.flatMap { entity -> APIService.Persist.PersistMemo in - createOrMergeToot( + createOrMergeStatus( into: managedObjectContext, for: requestMastodonUser, requestMastodonUserID: requestMastodonUserID, domain: domain, entity: entity, memoType: .reblog, - tootCache: tootCache, + statusCache: statusCache, userCache: userCache, networkDate: networkDate, log: log @@ -163,27 +163,27 @@ extension APIService.Persist.PersistMemo where T == Toot, U == MastodonUser { let children = [reblogMemo].compactMap { $0 } - let (toot, isTootCreated, isMastodonUserCreated) = APIService.CoreData.createOrMergeStatus( + let (status, isStatusCreated, isMastodonUserCreated) = APIService.CoreData.createOrMergeStatus( into: managedObjectContext, for: requestMastodonUser, domain: domain, entity: entity, - tootCache: tootCache, + statusCache: statusCache, userCache: userCache, networkDate: networkDate, log: log ) let memo = APIService.Persist.PersistMemo( - status: toot, + status: status, children: children, memoType: memoType, - statusProcessType: isTootCreated ? .create : .merge, + statusProcessType: isStatusCreated ? .create : .merge, authorProcessType: isMastodonUserCreated ? .create : .merge ) switch (memo.statusProcessType, memoType) { case (.create, .homeTimeline), (.merge, .homeTimeline): - let timelineIndex = toot.homeTimelineIndexes? + let timelineIndex = status.homeTimelineIndexes? .first { $0.userID == requestMastodonUserID } guard let requestMastodonUserID = requestMastodonUserID else { assertionFailure() @@ -192,7 +192,7 @@ extension APIService.Persist.PersistMemo where T == Toot, U == MastodonUser { if timelineIndex == nil { // make it indexed let timelineIndexProperty = HomeTimelineIndex.Property(domain: domain, userID: requestMastodonUserID) - let _ = HomeTimelineIndex.insert(into: managedObjectContext, property: timelineIndexProperty, toot: toot) + let _ = HomeTimelineIndex.insert(into: managedObjectContext, property: timelineIndexProperty, status: status) } else { // enity already in home timeline } diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift index 2944e66a5..dfd41094a 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift @@ -18,6 +18,7 @@ extension APIService.Persist { enum PersistTimelineType { case `public` case home + case user case likeList case lookUp } @@ -32,8 +33,8 @@ extension APIService.Persist { log: OSLog ) -> AnyPublisher, Never> { return managedObjectContext.performChanges { - let toots = response.value - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: persist %{public}ld toots…", ((#file as NSString).lastPathComponent), #line, #function, toots.count) + let statuses = response.value + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: persist %{public}ld statuses…", ((#file as NSString).lastPathComponent), #line, #function, statuses.count) let contextTaskSignpostID = OSSignpostID(log: log) let start = CACurrentMediaTime() @@ -61,18 +62,18 @@ extension APIService.Persist { // load working set into context to avoid cache miss let cacheTaskSignpostID = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: "load toots & users into cache", signpostID: cacheTaskSignpostID) + os_signpost(.begin, log: log, name: "load statuses & users into cache", signpostID: cacheTaskSignpostID) // contains reblog - let tootCache: PersistCache = { - let cache = PersistCache() - let cacheIDs = PersistCache.ids(for: toots) - let cachedToots: [Toot] = { - let request = Toot.sortedFetchRequest + let statusCache: PersistCache = { + let cache = PersistCache() + let cacheIDs = PersistCache.ids(for: statuses) + let cachedStatuses: [Status] = { + let request = Status.sortedFetchRequest let ids = Array(cacheIDs) - request.predicate = Toot.predicate(domain: domain, ids: ids) + request.predicate = Status.predicate(domain: domain, ids: ids) request.returnsObjectsAsFaults = false - request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] + request.relationshipKeyPathsForPrefetching = [#keyPath(Status.reblog)] do { return try managedObjectContext.fetch(request) } catch { @@ -80,16 +81,16 @@ extension APIService.Persist { return [] } }() - for toot in cachedToots { - cache.dictionary[toot.id] = toot + for status in cachedStatuses { + cache.dictionary[status.id] = status } - os_signpost(.event, log: log, name: "load toot into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld toots", cachedToots.count) + os_signpost(.event, log: log, name: "load status into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld statuses", cachedStatuses.count) return cache }() let userCache: PersistCache = { let cache = PersistCache() - let cacheIDs = PersistCache.ids(for: toots) + let cacheIDs = PersistCache.ids(for: statuses) let cachedMastodonUsers: [MastodonUser] = { let request = MastodonUser.sortedFetchRequest let ids = Array(cacheIDs) @@ -109,40 +110,41 @@ extension APIService.Persist { return cache }() - os_signpost(.end, log: log, name: "load toots & users into cache", signpostID: cacheTaskSignpostID) + os_signpost(.end, log: log, name: "load statuses & users into cache", signpostID: cacheTaskSignpostID) // remote timeline merge local timeline record set // declare it before persist - let mergedOldTootsInTimeline = tootCache.dictionary.values.filter { + let mergedOldStatusesInTimeline = statusCache.dictionary.values.filter { return $0.homeTimelineIndexes?.contains(where: { $0.userID == requestMastodonUserID }) ?? false } let updateDatabaseTaskSignpostID = OSSignpostID(log: log) - let memoType: PersistMemo.MemoType = { + let memoType: PersistMemo.MemoType = { switch persistType { case .home: return .homeTimeline case .public: return .publicTimeline + case .user: return .userTimeline case .likeList: return .likeList case .lookUp: return .lookUp } }() - var persistMemos: [PersistMemo] = [] + var persistMemos: [PersistMemo] = [] os_signpost(.begin, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID) - for entity in toots { + for entity in statuses { let processEntityTaskSignpostID = OSSignpostID(log: log) os_signpost(.begin, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id) defer { os_signpost(.end, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id) } - let memo = PersistMemo.createOrMergeToot( + let memo = PersistMemo.createOrMergeStatus( into: managedObjectContext, for: requestMastodonUser, requestMastodonUserID: requestMastodonUserID, domain: domain, entity: entity, memoType: memoType, - tootCache: tootCache, + statusCache: statusCache, userCache: userCache, networkDate: response.networkDate, log: log @@ -161,19 +163,19 @@ extension APIService.Persist { } // Task 1: update anchor hasMore // update maxID anchor hasMore attribute when fetching on home timeline - // do not use working records due to anchor toot is removable on the remote - var anchorToot: Toot? + // do not use working records due to anchor status is removable on the remote + var anchorStatus: Status? if let maxID = query.maxID { do { - // load anchor toot from database - let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(domain: domain, id: maxID) + // load anchor status from database + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: domain, id: maxID) request.returnsObjectsAsFaults = false request.fetchLimit = 1 - anchorToot = try managedObjectContext.fetch(request).first + anchorStatus = try managedObjectContext.fetch(request).first if persistType == .home { - let timelineIndex = anchorToot.flatMap { toot in - toot.homeTimelineIndexes?.first(where: { $0.userID == requestMastodonUserID }) + let timelineIndex = anchorStatus.flatMap { status in + status.homeTimelineIndexes?.first(where: { $0.userID == requestMastodonUserID }) } timelineIndex?.update(hasMore: false) } else { @@ -184,16 +186,16 @@ extension APIService.Persist { } } - // Task 2: set last toot hasMore when fetched toots not overlap with the timeline in the local database + // Task 2: set last status hasMore when fetched statuses not overlap with the timeline in the local database let _oldestMemo = persistMemos .sorted(by: { $0.status.createdAt < $1.status.createdAt }) .first if let oldestMemo = _oldestMemo { - if let anchorToot = anchorToot { + if let anchorStatus = anchorStatus { // using anchor. set hasMore when (overlap itself OR no overlap) AND oldest record NOT anchor - let isNoOverlap = mergedOldTootsInTimeline.isEmpty - let isOnlyOverlapItself = mergedOldTootsInTimeline.count == 1 && mergedOldTootsInTimeline.first?.id == anchorToot.id - let isAnchorEqualOldestRecord = oldestMemo.status.id == anchorToot.id + let isNoOverlap = mergedOldStatusesInTimeline.isEmpty + let isOnlyOverlapItself = mergedOldStatusesInTimeline.count == 1 && mergedOldStatusesInTimeline.first?.id == anchorStatus.id + let isAnchorEqualOldestRecord = oldestMemo.status.id == anchorStatus.id if (isNoOverlap || isOnlyOverlapItself) && !isAnchorEqualOldestRecord { if persistType == .home { let timelineIndex = oldestMemo.status.homeTimelineIndexes? @@ -204,7 +206,7 @@ extension APIService.Persist { } } - } else if mergedOldTootsInTimeline.isEmpty { + } else if mergedOldStatusesInTimeline.isEmpty { // no anchor. set hasMore when no overlap if persistType == .home { let timelineIndex = oldestMemo.status.homeTimelineIndexes? @@ -232,7 +234,7 @@ extension APIService.Persist { let newTweetsInTimeLineCount = persistMemos.reduce(0, { result, next in return next.statusProcessType == .create ? result + 1 : result }) - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: tweet: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldTootsInTimeline.count, counting.status.merge) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: tweet: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldStatusesInTimeline.count, counting.status.merge) os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: twitter user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge) } #endif diff --git a/Mastodon/Service/StatusPrefetchingService.swift b/Mastodon/Service/StatusPrefetchingService.swift index 5d0191ff3..e1337204b 100644 --- a/Mastodon/Service/StatusPrefetchingService.swift +++ b/Mastodon/Service/StatusPrefetchingService.swift @@ -58,10 +58,10 @@ extension StatusPrefetchingService { guard let self = self else { return } let backgroundManagedObjectContext = apiService.backgroundManagedObjectContext backgroundManagedObjectContext.performChanges { - guard let status = backgroundManagedObjectContext.object(with: statusObjectID) as? Toot else { return } + guard let status = backgroundManagedObjectContext.object(with: statusObjectID) as? Status else { return } do { - let predicate = Toot.predicate(domain: domain, id: replyToStatusID) - let request = Toot.sortedFetchRequest + let predicate = Status.predicate(domain: domain, id: replyToStatusID) + let request = Status.sortedFetchRequest request.predicate = predicate request.returnsObjectsAsFaults = false request.fetchLimit = 1 diff --git a/Mastodon/Service/ViedeoPlaybackService.swift b/Mastodon/Service/ViedeoPlaybackService.swift index 15348c6e9..523af3103 100644 --- a/Mastodon/Service/ViedeoPlaybackService.swift +++ b/Mastodon/Service/ViedeoPlaybackService.swift @@ -101,8 +101,8 @@ extension VideoPlaybackService { } extension VideoPlaybackService { - func markTransitioning(for toot: Toot) { - guard let videoAttachment = toot.mediaAttachments?.filter({ $0.type == .gifv || $0.type == .video }).first else { return } + func markTransitioning(for status: Status) { + guard let videoAttachment = status.mediaAttachments?.filter({ $0.type == .gifv || $0.type == .video }).first else { return } guard let videoPlayerViewModel = dequeueVideoPlayerViewModel(for: videoAttachment) else { return } videoPlayerViewModel.isTransitioning = true } diff --git a/Mastodon/ViewController.swift b/Mastodon/ViewController.swift new file mode 100644 index 000000000..9be4589cc --- /dev/null +++ b/Mastodon/ViewController.swift @@ -0,0 +1,15 @@ +// +// ViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-31. +// + +import UIKit + +class ViewController: UIViewController { + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .darkContent + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift new file mode 100644 index 000000000..ec8bf9d5e --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift @@ -0,0 +1,69 @@ +// +// Mastodon+API+Account+Friendship.swift +// +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import Foundation +import Combine + +extension Mastodon.API.Account { + + static func accountsRelationshipsEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/relationships") + } + + /// Check relationships to other accounts + /// + /// Find out whether a given account is followed, blocked, muted, etc. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/#perform-actions-on-an-account/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `RelationshipQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `[Relationship]` nested in the response + public static func relationships( + session: URLSession, + domain: String, + query: RelationshipQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: accountsRelationshipsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Relationship].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct RelationshipQuery: GetQuery { + public let ids: [Mastodon.Entity.Account.ID] + + public init(ids: [Mastodon.Entity.Account.ID]) { + self.ids = ids + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + for id in ids { + items.append(URLQueryItem(name: "id[]", value: id)) + } + guard !items.isEmpty else { return nil } + return items + } + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift index 3bb81dc6b..0f98dbe05 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift @@ -53,3 +53,82 @@ extension Mastodon.API.Account { } } + +extension Mastodon.API.Account { + + static func accountStatusesEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/\(accountID)/statuses") + } + + /// View statuses from followed users. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/30 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `AccountStatuseseQuery` with query parameters + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func statuses( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + query: AccountStatuseseQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: accountStatusesEndpointURL(domain: domain, accountID: accountID), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Status].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct AccountStatuseseQuery: GetQuery { + public let maxID: Mastodon.Entity.Status.ID? + public let sinceID: Mastodon.Entity.Status.ID? + public let excludeReplies: Bool? // undocumented + public let excludeReblogs: Bool? + public let onlyMedia: Bool? + public let limit: Int? + + public init( + maxID: Mastodon.Entity.Status.ID?, + sinceID: Mastodon.Entity.Status.ID?, + excludeReplies: Bool?, + excludeReblogs: Bool?, + onlyMedia: Bool?, + limit: Int? + ) { + self.maxID = maxID + self.sinceID = sinceID + self.excludeReplies = excludeReplies + self.excludeReblogs = excludeReblogs + self.onlyMedia = onlyMedia + self.limit = limit + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + excludeReplies.flatMap { items.append(URLQueryItem(name: "exclude_replies", value: $0.queryItemValue)) } + excludeReblogs.flatMap { items.append(URLQueryItem(name: "exclude_reblogs", value: $0.queryItemValue)) } + onlyMedia.flatMap { items.append(URLQueryItem(name: "only_media", value: $0.queryItemValue)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items + } + } + +} From efbb32648cab53e199daaded4a821df45ead3090 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 1 Apr 2021 14:54:15 +0800 Subject: [PATCH 2/2] chore: update naming for StatusFetchedResultsController --- .../StatusFetchedResultsController.swift | 4 ++-- Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift index 704f2ab78..a61429ab8 100644 --- a/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift +++ b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift @@ -23,7 +23,7 @@ final class StatusFetchedResultsController: NSObject { let statusIDs = CurrentValueSubject<[Mastodon.Entity.Status.ID], Never>([]) // output - let items = CurrentValueSubject<[NSManagedObjectID], Never>([]) + let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate) { self.domain.value = domain ?? "" @@ -81,6 +81,6 @@ extension StatusFetchedResultsController: NSFetchedResultsControllerDelegate { } .sorted { $0.0 < $1.0 } .map { $0.1.objectID } - self.items.value = items + self.objectIDs.value = items } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index de17376d2..a550dc829 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -60,7 +60,7 @@ class UserTimelineViewModel: NSObject { .store(in: &disposeBag) - statusFetchedResultsController.items + statusFetchedResultsController.objectIDs .receive(on: DispatchQueue.main) .sink { [weak self] objectIDs in guard let self = self else { return }