diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 3fe5fe16..3f8fe73f 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -83,6 +83,8 @@ + + @@ -93,6 +95,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -128,9 +152,10 @@ - + + @@ -138,14 +163,16 @@ + - + + + - - + \ No newline at end of file diff --git a/CoreDataStack/Entity/Application.swift b/CoreDataStack/Entity/Application.swift index cfbf48f7..c9aa2283 100644 --- a/CoreDataStack/Entity/Application.swift +++ b/CoreDataStack/Entity/Application.swift @@ -24,7 +24,7 @@ public final class Application: NSManagedObject { public extension Application { override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + setPrimitiveValue(UUID(), forKey: #keyPath(Application.identifier)) } @discardableResult diff --git a/CoreDataStack/Entity/Attachment.swift b/CoreDataStack/Entity/Attachment.swift index f3071872..e580014c 100644 --- a/CoreDataStack/Entity/Attachment.swift +++ b/CoreDataStack/Entity/Attachment.swift @@ -36,7 +36,7 @@ public extension Attachment { override func awakeFromInsert() { super.awakeFromInsert() - createdAt = Date() + setPrimitiveValue(Date(), forKey: #keyPath(Attachment.createdAt)) } @discardableResult diff --git a/CoreDataStack/Entity/Emoji.swift b/CoreDataStack/Entity/Emoji.swift index f43dcbf4..933baab9 100644 --- a/CoreDataStack/Entity/Emoji.swift +++ b/CoreDataStack/Entity/Emoji.swift @@ -26,7 +26,7 @@ public final class Emoji: NSManagedObject { public extension Emoji { override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + setPrimitiveValue(UUID(), forKey: #keyPath(Emoji.identifier)) } @discardableResult diff --git a/CoreDataStack/Entity/History.swift b/CoreDataStack/Entity/History.swift index 66493368..552e2a40 100644 --- a/CoreDataStack/Entity/History.swift +++ b/CoreDataStack/Entity/History.swift @@ -24,7 +24,7 @@ public final class History: NSManagedObject { public extension History { override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + setPrimitiveValue(UUID(), forKey: #keyPath(History.identifier)) } @discardableResult diff --git a/CoreDataStack/Entity/MastodonAuthentication.swift b/CoreDataStack/Entity/MastodonAuthentication.swift index e58c2e87..0ee0e343 100644 --- a/CoreDataStack/Entity/MastodonAuthentication.swift +++ b/CoreDataStack/Entity/MastodonAuthentication.swift @@ -36,12 +36,12 @@ extension MastodonAuthentication { public override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + setPrimitiveValue(UUID(), forKey: #keyPath(MastodonAuthentication.identifier)) let now = Date() - createdAt = now - updatedAt = now - activedAt = now + setPrimitiveValue(now, forKey: #keyPath(MastodonAuthentication.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(MastodonAuthentication.updatedAt)) + setPrimitiveValue(now, forKey: #keyPath(MastodonAuthentication.activedAt)) } @discardableResult diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index bcbfe5d2..dc88d48a 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -37,6 +37,8 @@ final public class MastodonUser: NSManagedObject { @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? } diff --git a/CoreDataStack/Entity/Mention.swift b/CoreDataStack/Entity/Mention.swift index caec10d3..e659cf89 100644 --- a/CoreDataStack/Entity/Mention.swift +++ b/CoreDataStack/Entity/Mention.swift @@ -25,7 +25,8 @@ public final class Mention: NSManagedObject { public extension Mention { override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + + setPrimitiveValue(UUID(), forKey: #keyPath(Mention.identifier)) } @discardableResult diff --git a/CoreDataStack/Entity/Poll.swift b/CoreDataStack/Entity/Poll.swift new file mode 100644 index 00000000..356f2fc2 --- /dev/null +++ b/CoreDataStack/Entity/Poll.swift @@ -0,0 +1,145 @@ +// +// Poll.swift +// CoreDataStack +// +// Created by MainasuK Cirno on 2021-3-2. +// + +import Foundation +import CoreData + +public final class Poll: NSManagedObject { + public typealias ID = String + + @NSManaged public private(set) var id: ID + @NSManaged public private(set) var expiresAt: Date? + @NSManaged public private(set) var expired: Bool + @NSManaged public private(set) var multiple: Bool + @NSManaged public private(set) var votesCount: NSNumber + @NSManaged public private(set) var votersCount: NSNumber? + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // one-to-one relationship + @NSManaged public private(set) var toot: Toot + + // one-to-many relationship + @NSManaged public private(set) var options: Set + + // many-to-many relationship + @NSManaged public private(set) var votedBy: Set? +} + +extension Poll { + + public override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(Poll.createdAt)) + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property, + votedBy: MastodonUser?, + options: [PollOption] + ) -> Poll { + let poll: Poll = context.insertObject() + + poll.id = property.id + poll.expiresAt = property.expiresAt + poll.expired = property.expired + poll.multiple = property.multiple + poll.votesCount = property.votesCount + poll.votersCount = property.votersCount + + + poll.updatedAt = property.networkDate + + if let votedBy = votedBy { + poll.mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(votedBy) + } + poll.mutableSetValue(forKey: #keyPath(Poll.options)).addObjects(from: options) + + return poll + } + + public func update(expiresAt: Date?) { + if self.expiresAt != expiresAt { + self.expiresAt = expiresAt + } + } + + public func update(expired: Bool) { + if self.expired != expired { + self.expired = expired + } + } + + public func update(votesCount: Int) { + if self.votesCount.intValue != votesCount { + self.votesCount = NSNumber(value: votesCount) + } + } + + public func update(votersCount: Int?) { + if self.votersCount?.intValue != votersCount { + self.votersCount = votersCount.flatMap { NSNumber(value: $0) } + } + } + + public func update(voted: Bool, by: MastodonUser) { + if voted { + if !(votedBy ?? Set()).contains(by) { + mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(by) + } + } else { + if (votedBy ?? Set()).contains(by) { + mutableSetValue(forKey: #keyPath(Poll.votedBy)).remove(by) + } + } + } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + +} + +extension Poll { + public struct Property { + public let id: ID + public let expiresAt: Date? + public let expired: Bool + public let multiple: Bool + public let votesCount: NSNumber + public let votersCount: NSNumber? + + public let networkDate: Date + + public init( + id: Poll.ID, + expiresAt: Date?, + expired: Bool, + multiple: Bool, + votesCount: Int, + votersCount: Int?, + networkDate: Date + ) { + self.id = id + self.expiresAt = expiresAt + self.expired = expired + self.multiple = multiple + self.votesCount = NSNumber(value: votesCount) + self.votersCount = votersCount.flatMap { NSNumber(value: $0) } + self.networkDate = networkDate + } + } +} + +extension Poll: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Poll.createdAt, ascending: false)] + } +} diff --git a/CoreDataStack/Entity/PollOption.swift b/CoreDataStack/Entity/PollOption.swift new file mode 100644 index 00000000..8917a753 --- /dev/null +++ b/CoreDataStack/Entity/PollOption.swift @@ -0,0 +1,98 @@ +// +// PollOption.swift +// CoreDataStack +// +// Created by MainasuK Cirno on 2021-3-2. +// + +import Foundation +import CoreData + +public final class PollOption: NSManagedObject { + @NSManaged public private(set) var index: NSNumber + @NSManaged public private(set) var title: String + @NSManaged public private(set) var votesCount: NSNumber? + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // many-to-one relationship + @NSManaged public private(set) var poll: Poll + + // many-to-many relationship + @NSManaged public private(set) var votedBy: Set? +} + +extension PollOption { + + public override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(PollOption.createdAt)) + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property, + votedBy: MastodonUser? + ) -> PollOption { + let option: PollOption = context.insertObject() + + option.index = property.index + option.title = property.title + option.votesCount = property.votesCount + option.updatedAt = property.networkDate + + if let votedBy = votedBy { + option.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(votedBy) + } + + return option + } + + public func update(votesCount: Int?) { + if self.votesCount?.intValue != votesCount { + self.votesCount = votesCount.flatMap { NSNumber(value: $0) } + } + } + + public func update(voted: Bool, by: MastodonUser) { + if voted { + if !(self.votedBy ?? Set()).contains(by) { + self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(by) + } + } else { + if (self.votedBy ?? Set()).contains(by) { + self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).remove(by) + } + } + } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + +} + +extension PollOption { + public struct Property { + public let index: NSNumber + public let title: String + public let votesCount: NSNumber? + + public let networkDate: Date + + public init(index: Int, title: String, votesCount: Int?, networkDate: Date) { + self.index = NSNumber(value: index) + self.title = title + self.votesCount = votesCount.flatMap { NSNumber(value: $0) } + self.networkDate = networkDate + } + } +} + +extension PollOption: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \PollOption.createdAt, ascending: false)] + } +} diff --git a/CoreDataStack/Entity/Tag.swift b/CoreDataStack/Entity/Tag.swift index b5d8be68..d817c774 100644 --- a/CoreDataStack/Entity/Tag.swift +++ b/CoreDataStack/Entity/Tag.swift @@ -23,13 +23,14 @@ public final class Tag: NSManagedObject { @NSManaged public private(set) var histories: Set? } -public extension Tag { - override func awakeFromInsert() { +extension Tag { + public override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + setPrimitiveValue(UUID(), forKey: #keyPath(Tag.identifier)) } + @discardableResult - static func insert( + public static func insert( into context: NSManagedObjectContext, property: Property ) -> Tag { @@ -43,8 +44,8 @@ public extension Tag { } } -public extension Tag { - struct Property { +extension Tag { + public struct Property { public let name: String public let url: String public let histories: [History]? diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Toot.swift index b37609a2..c5fcf486 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Toot.swift @@ -48,6 +48,7 @@ public final class Toot: NSManagedObject { // one-to-one relastionship @NSManaged public private(set) var pinnedBy: MastodonUser? + @NSManaged public private(set) var poll: Poll? // one-to-many relationship @NSManaged public private(set) var reblogFrom: Set? @@ -69,6 +70,7 @@ public extension Toot { author: MastodonUser, reblog: Toot?, application: Application?, + poll: Poll?, mentions: [Mention]?, emojis: [Emoji]?, tags: [Tag]?, @@ -109,6 +111,7 @@ public extension Toot { toot.reblog = reblog toot.pinnedBy = pinnedBy + toot.poll = poll if let mentions = mentions { toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: mentions) diff --git a/Localization/app.json b/Localization/app.json index 58807fc2..12365595 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -1,11 +1,19 @@ { "common": { "alerts": { + "common": { + "please_try_again": "Please try again.", + "please_try_again_later": "Please try again later." + }, "sign_up_failure": { "title": "Sign Up Failure" }, "server_error": { "title": "Server Error" + }, + "vote_failure": { + "title": "Vote Failure", + "poll_expired": "The poll has expired" } }, "controls": { @@ -31,7 +39,20 @@ "user_boosted": "%s boosted", "show_post": "Show Post", "status_content_warning": "content warning", - "media_content_warning": "Tap to reveal that may be sensitive" + "media_content_warning": "Tap to reveal that may be sensitive", + "poll": { + "vote": "Vote", + "vote_count": { + "single": "%d vote", + "multiple": "%d votes", + }, + "voter_count": { + "single": "%d voter", + "multiple": "%d voters", + }, + "time_left": "%s left", + "closed": "Closed" + } }, "timeline": { "load_more": "Load More" @@ -154,4 +175,4 @@ "title": "Public" } } -} +} \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 115a32ed..b3abfd84 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -79,7 +79,7 @@ 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; }; 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */; }; - 2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */; }; + 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */; }; 2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */; }; 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; }; 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; }; @@ -96,12 +96,13 @@ 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 */; }; + DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */; }; + DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */; }; + DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E347725F519300079D7DF /* PickServerItem.swift */; }; DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */; }; DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */; }; - DB1FD44A25F26CD7004CFCFC /* PickServerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44925F26CD7004CFCFC /* PickServerItem.swift */; }; DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */; }; DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */; }; - DB1FD46025F278AF004CFCFC /* CategoryPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */; }; DB2B3ABC25E37E15007045F9 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */; }; DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; @@ -114,6 +115,12 @@ DB427DE225BAA00100D1B89D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */; }; DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; }; DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; }; + DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44384E25E8C1FA008912A2 /* CALayer.swift */; }; + DB4481AD25EE155900BEFB67 /* Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481AC25EE155900BEFB67 /* Poll.swift */; }; + DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B225EE16D000BEFB67 /* PollOption.swift */; }; + DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B825EE289600BEFB67 /* UITableView.swift */; }; + DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481C525EE2ADA00BEFB67 /* PollSection.swift */; }; + DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */; }; DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */; }; DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */; }; DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */; }; @@ -127,6 +134,10 @@ DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */; }; + DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; }; + DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; }; + DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; }; + DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F11725EFA35B001F1DAB /* StripProgressView.swift */; }; DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; @@ -157,6 +168,7 @@ DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; }; DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; }; DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */; }; + DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */; }; DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; }; DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; }; DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; }; @@ -297,7 +309,7 @@ 2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; 2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = ""; }; 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProviderFacade.swift; sourceTree = ""; }; - 2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+TimelinePostTableViewCellDelegate.swift"; sourceTree = ""; }; + 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusTableViewCellDelegate.swift"; sourceTree = ""; }; 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Favorite.swift"; sourceTree = ""; }; 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectObserver.swift; sourceTree = ""; }; 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = ""; }; @@ -318,9 +330,11 @@ 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 = ""; }; + DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = ""; }; + DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = ""; }; + DB1E347725F519300079D7DF /* PickServerItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PickServerItem.swift; sourceTree = ""; }; DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+LoadIndexedServerState.swift"; sourceTree = ""; }; DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSection.swift; sourceTree = ""; }; - DB1FD44925F26CD7004CFCFC /* PickServerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PickServerItem.swift; path = Mastodon/Diffiable/Section/PickServerItem.swift; sourceTree = SOURCE_ROOT; }; DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+Diffable.swift"; sourceTree = ""; }; DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerItem.swift; sourceTree = ""; }; DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = ""; }; @@ -342,6 +356,12 @@ DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MastodonUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = ""; }; DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DB44384E25E8C1FA008912A2 /* CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CALayer.swift; sourceTree = ""; }; + DB4481AC25EE155900BEFB67 /* Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poll.swift; sourceTree = ""; }; + DB4481B225EE16D000BEFB67 /* PollOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOption.swift; sourceTree = ""; }; + DB4481B825EE289600BEFB67 /* UITableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITableView.swift; sourceTree = ""; }; + DB4481C525EE2ADA00BEFB67 /* PollSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollSection.swift; sourceTree = ""; }; + DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollItem.swift; sourceTree = ""; }; DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardResponderService.swift; sourceTree = ""; }; DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertController.swift; sourceTree = ""; }; DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBarButtonItem.swift; sourceTree = ""; }; @@ -354,6 +374,10 @@ DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; + DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = ""; }; + DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; }; + DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = ""; }; + DB59F11725EFA35B001F1DAB /* StripProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripProgressView.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 = ""; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; @@ -386,6 +410,7 @@ DB8AF55C25C138B7002E6C99 /* UIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = ""; }; DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerEmptyStateView.swift; sourceTree = ""; }; + DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionTableViewCell.swift; sourceTree = ""; }; DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = ""; }; DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = ""; }; DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = ""; }; @@ -563,7 +588,8 @@ children = ( 2D38F1FD25CD481700561493 /* StatusProvider.swift */, 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */, - 2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */, + 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */, + DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */, ); path = StatusProvider; sourceTree = ""; @@ -629,9 +655,10 @@ 2D38F1FC25CD47D900561493 /* StatusProvider */, DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */, 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */, + 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */, + DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */, 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */, 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */, - 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */, ); path = Protocol; sourceTree = ""; @@ -652,8 +679,8 @@ 2D76319C25C151DE00929FB9 /* Diffiable */ = { isa = PBXGroup; children = ( - 2D7631B125C159E700929FB9 /* Item */, 2D76319D25C151F600929FB9 /* Section */, + 2D7631B125C159E700929FB9 /* Item */, ); path = Diffiable; sourceTree = ""; @@ -662,8 +689,9 @@ isa = PBXGroup; children = ( 2D76319E25C1521200929FB9 /* StatusSection.swift */, + DB4481C525EE2ADA00BEFB67 /* PollSection.swift */, DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */, - DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */, + DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, ); path = Section; sourceTree = ""; @@ -684,7 +712,9 @@ 2D42FF8325C82245004A627A /* Button */, 2D42FF7C25C82207004A627A /* ToolBar */, DB9D6C1325E4F97A0051B173 /* Container */, + DBA9B90325F1D4420012E7B6 /* Control */, 2D152A8A25C295B8009AA50C /* Content */, + DB1D187125EF5BBD003F1F23 /* TableView */, 2D7631A625C1533800929FB9 /* TableviewCell */, ); path = View; @@ -697,6 +727,7 @@ 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */, 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */, + DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */, ); path = TableviewCell; sourceTree = ""; @@ -705,7 +736,8 @@ isa = PBXGroup; children = ( 2D7631B225C159F700929FB9 /* Item.swift */, - DB1FD44925F26CD7004CFCFC /* PickServerItem.swift */, + DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */, + DB1E347725F519300079D7DF /* PickServerItem.swift */, DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, ); path = Item; @@ -765,6 +797,14 @@ path = CoreDataStack; sourceTree = ""; }; + DB1D187125EF5BBD003F1F23 /* TableView */ = { + isa = PBXGroup; + children = ( + DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */, + ); + path = TableView; + sourceTree = ""; + }; DB3D0FF725BAA68500EAA174 /* Supporting Files */ = { isa = PBXGroup; children = ( @@ -863,15 +903,16 @@ DB45FB0925CA87BC005A8AC7 /* CoreData */, 2D61335625C1887F00CAE157 /* Persist */, 2D61335D25C1894B00CAE157 /* APIService.swift */, + DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */, 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */, - DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, DB98336A25C9420100AD9700 /* APIService+App.swift */, DB98337025C9443200AD9700 /* APIService+Authentication.swift */, DB98339B25C96DE600AD9700 /* APIService+Account.swift */, 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */, DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */, DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */, + DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, ); path = APIService; sourceTree = ""; @@ -977,6 +1018,8 @@ DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */, 2DA7D05625CA693F00804E11 /* Application.swift */, DB9D6C2D25E504AC0051B173 /* Attachment.swift */, + DB4481AC25EE155900BEFB67 /* Poll.swift */, + DB4481B225EE16D000BEFB67 /* PollOption.swift */, ); path = Entity; sourceTree = ""; @@ -1037,6 +1080,7 @@ children = ( DB084B5125CBC56300F898ED /* CoreDataStack */, DB6C8C0525F0921200AAA452 /* MastodonSDK */, + DB44384E25E8C1FA008912A2 /* CALayer.swift */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, @@ -1049,6 +1093,7 @@ DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */, DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */, + DB4481B825EE289600BEFB67 /* UITableView.swift */, 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, 0FAA101B25E10E760017CCDE /* UIFont.swift */, 2D939AB425EDD8A90076FA61 /* String.swift */, @@ -1061,6 +1106,7 @@ children = ( CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */, 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */, + DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */, ); name = "Recovered References"; sourceTree = ""; @@ -1114,6 +1160,14 @@ path = ViewModel; sourceTree = ""; }; + DBA9B90325F1D4420012E7B6 /* Control */ = { + isa = PBXGroup; + children = ( + DB59F11725EFA35B001F1DAB /* StripProgressView.swift */, + ); + path = Control; + sourceTree = ""; + }; DBE0821A25CD382900FD6BBD /* Register */ = { isa = PBXGroup; children = ( @@ -1488,10 +1542,11 @@ 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, + DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */, DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */, 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, - DB1FD44A25F26CD7004CFCFC /* PickServerItem.swift in Sources */, + DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */, DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */, 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, @@ -1502,12 +1557,15 @@ DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, + DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */, + DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, + DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */, @@ -1519,6 +1577,8 @@ DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, + DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, + DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, @@ -1532,18 +1592,22 @@ 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, + DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, - 2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */, + 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, + DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, + DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, + DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, @@ -1556,6 +1620,7 @@ 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, + DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, @@ -1568,7 +1633,6 @@ DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, - DB1FD46025F278AF004CFCFC /* CategoryPickerSection.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, @@ -1632,8 +1696,10 @@ 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 */, 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */, + DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */, DB89BA4425C1165F008580ED /* Managed.swift in Sources */, DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */, DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index d9c64a5e..60ccd3d8 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 8 + 7 Mastodon - RTL.xcscheme_^#shared#^_ @@ -22,7 +22,7 @@ Mastodon.xcscheme_^#shared#^_ orderHint - 7 + 8 SuppressBuildableAutocreation diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index 818c33ea..63c73d3a 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -13,10 +13,10 @@ import MastodonSDK /// Note: update Equatable when change case enum Item { // timeline - case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusTimelineAttribute) + case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusAttribute) // normal list - case toot(objectID: NSManagedObjectID, attribute: StatusTimelineAttribute) + case toot(objectID: NSManagedObjectID, attribute: StatusAttribute) // loader case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID) @@ -30,7 +30,7 @@ protocol StatusContentWarningAttribute { } extension Item { - class StatusTimelineAttribute: Equatable, Hashable, StatusContentWarningAttribute { + class StatusAttribute: Equatable, Hashable, StatusContentWarningAttribute { var isStatusTextSensitive: Bool var isStatusSensitive: Bool @@ -42,7 +42,7 @@ extension Item { self.isStatusSensitive = isStatusSensitive } - static func == (lhs: Item.StatusTimelineAttribute, rhs: Item.StatusTimelineAttribute) -> Bool { + static func == (lhs: Item.StatusAttribute, rhs: Item.StatusAttribute) -> Bool { return lhs.isStatusTextSensitive == rhs.isStatusTextSensitive && lhs.isStatusSensitive == rhs.isStatusSensitive } diff --git a/Mastodon/Diffiable/Section/PickServerItem.swift b/Mastodon/Diffiable/Item/PickServerItem.swift similarity index 100% rename from Mastodon/Diffiable/Section/PickServerItem.swift rename to Mastodon/Diffiable/Item/PickServerItem.swift diff --git a/Mastodon/Diffiable/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift new file mode 100644 index 00000000..006400f9 --- /dev/null +++ b/Mastodon/Diffiable/Item/PollItem.swift @@ -0,0 +1,67 @@ +// +// PollItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-2. +// + +import Foundation +import CoreData + +enum PollItem { + case opion(objectID: NSManagedObjectID, attribute: Attribute) +} + + +extension PollItem { + class Attribute: Hashable { + + enum SelectState: Equatable, Hashable { + case none + case off + case on + } + + enum VoteState: Equatable, Hashable { + case hidden + case reveal(voted: Bool, percentage: Double, animated: Bool) + } + + var selectState: SelectState + var voteState: VoteState + + init(selectState: SelectState, voteState: VoteState) { + self.selectState = selectState + self.voteState = voteState + } + + static func == (lhs: PollItem.Attribute, rhs: PollItem.Attribute) -> Bool { + return lhs.selectState == rhs.selectState && + lhs.voteState == rhs.voteState + } + + func hash(into hasher: inout Hasher) { + hasher.combine(selectState) + hasher.combine(voteState) + } + } +} + +extension PollItem: Equatable { + static func == (lhs: PollItem, rhs: PollItem) -> Bool { + switch (lhs, rhs) { + case (.opion(let objectIDLeft, _), .opion(let objectIDRight, _)): + return objectIDLeft == objectIDRight + } + } +} + + +extension PollItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .opion(let objectID, _): + hasher.combine(objectID) + } + } +} diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift new file mode 100644 index 00000000..45da63bd --- /dev/null +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -0,0 +1,87 @@ +// +// PollSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-2. +// + +import UIKit +import CoreData +import CoreDataStack + +enum PollSection: Equatable, Hashable { + case main +} + +extension PollSection { + static func tableViewDiffableDataSource( + for tableView: UITableView, + managedObjectContext: NSManagedObjectContext + ) -> UITableViewDiffableDataSource { + return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in + switch item { + case .opion(let objectID, let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PollOptionTableViewCell.self), for: indexPath) as! PollOptionTableViewCell + managedObjectContext.performAndWait { + let option = managedObjectContext.object(with: objectID) as! PollOption + PollSection.configure(cell: cell, pollOption: option, pollItemAttribute: attribute) + } + return cell + } + } + } +} + +extension PollSection { + static func configure( + cell: PollOptionTableViewCell, + pollOption option: PollOption, + pollItemAttribute attribute: PollItem.Attribute + ) { + cell.optionLabel.text = option.title + configure(cell: cell, selectState: attribute.selectState) + configure(cell: cell, voteState: attribute.voteState) + cell.attribute = attribute + cell.layoutIfNeeded() + cell.updateTextAppearance() + } +} + +extension PollSection { + + static func configure(cell: PollOptionTableViewCell, selectState state: PollItem.Attribute.SelectState) { + switch state { + case .none: + cell.checkmarkBackgroundView.isHidden = true + cell.checkmarkImageView.isHidden = true + case .off: + cell.checkmarkBackgroundView.backgroundColor = .systemBackground + cell.checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor + cell.checkmarkBackgroundView.layer.borderWidth = 1 + cell.checkmarkBackgroundView.isHidden = false + cell.checkmarkImageView.isHidden = true + case .on: + cell.checkmarkBackgroundView.backgroundColor = .systemBackground + cell.checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor + cell.checkmarkBackgroundView.layer.borderWidth = 0 + cell.checkmarkBackgroundView.isHidden = false + cell.checkmarkImageView.isHidden = false + } + } + + static func configure(cell: PollOptionTableViewCell, voteState state: PollItem.Attribute.VoteState) { + switch state { + case .hidden: + cell.optionPercentageLabel.isHidden = true + cell.voteProgressStripView.isHidden = true + cell.voteProgressStripView.setProgress(0.0, animated: false) + case .reveal(let voted, let percentage, let animated): + cell.optionPercentageLabel.isHidden = false + cell.optionPercentageLabel.text = String(Int(100 * percentage)) + "%" + cell.voteProgressStripView.isHidden = false + cell.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color + cell.voteProgressStripView.setProgress(CGFloat(percentage), animated: animated) + } + } + +} diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 4fac88b4..5f9d43ed 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -21,11 +21,11 @@ extension StatusSection { dependency: NeedsDependency, managedObjectContext: NSManagedObjectContext, timestampUpdatePublisher: AnyPublisher, - timelinePostTableViewCellDelegate: StatusTableViewCellDelegate, + statusTableViewCellDelegate: StatusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { [weak timelinePostTableViewCellDelegate, weak timelineMiddleLoaderTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in - guard let timelinePostTableViewCellDelegate = timelinePostTableViewCellDelegate else { return UITableViewCell() } + UITableViewDiffableDataSource(tableView: tableView) { [weak statusTableViewCellDelegate, weak timelineMiddleLoaderTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in + guard let statusTableViewCellDelegate = statusTableViewCellDelegate else { return UITableViewCell() } switch item { case .homeTimelineIndex(objectID: let objectID, let attribute): @@ -34,9 +34,9 @@ extension StatusSection { // configure cell managedObjectContext.performAndWait { let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex - StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusContentWarningAttribute: attribute) + StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusItemAttribute: attribute) } - cell.delegate = timelinePostTableViewCellDelegate + cell.delegate = statusTableViewCellDelegate return cell case .toot(let objectID, let attribute): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell @@ -45,9 +45,9 @@ extension StatusSection { // configure cell managedObjectContext.performAndWait { let toot = managedObjectContext.object(with: objectID) as! Toot - StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusContentWarningAttribute: attribute) + StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusItemAttribute: attribute) } - cell.delegate = timelinePostTableViewCellDelegate + cell.delegate = statusTableViewCellDelegate return cell case .publicMiddleLoader(let upperTimelineTootID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell @@ -66,6 +66,9 @@ extension StatusSection { } } } +} + +extension StatusSection { static func configure( cell: StatusTableViewCell, @@ -73,7 +76,7 @@ extension StatusSection { timestampUpdatePublisher: AnyPublisher, toot: Toot, requestUserID: String, - statusContentWarningAttribute: StatusContentWarningAttribute? + statusItemAttribute: Item.StatusAttribute ) { // set header cell.statusView.headerContainerStackView.isHidden = toot.reblog == nil @@ -96,7 +99,7 @@ extension StatusSection { // set status text content warning let spoilerText = (toot.reblog ?? toot).spoilerText ?? "" - let isStatusTextSensitive = statusContentWarningAttribute?.isStatusTextSensitive ?? !spoilerText.isEmpty + let isStatusTextSensitive = statusItemAttribute.isStatusTextSensitive cell.statusView.isStatusTextSensitive = isStatusTextSensitive cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive) cell.statusView.contentWarningTitle.text = { @@ -132,14 +135,14 @@ extension StatusSection { }() if mosiacImageViewModel.metas.count == 1 { let meta = mosiacImageViewModel.metas[0] - let imageView = cell.statusView.statusMosaicImageView.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize) + let imageView = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize) imageView.af.setImage( withURL: meta.url, placeholderImage: UIImage.placeholder(color: .systemFill), imageTransition: .crossDissolve(0.2) ) } else { - let imageViews = cell.statusView.statusMosaicImageView.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height) + let imageViews = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height) for (i, imageView) in imageViews.enumerated() { let meta = mosiacImageViewModel.metas[i] imageView.af.setImage( @@ -149,11 +152,38 @@ extension StatusSection { ) } } - cell.statusView.statusMosaicImageView.isHidden = mosiacImageViewModel.metas.isEmpty - let isStatusSensitive = statusContentWarningAttribute?.isStatusSensitive ?? (toot.reblog ?? toot).sensitive - cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil - cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 - + cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty + let isStatusSensitive = statusItemAttribute.isStatusSensitive + cell.statusView.statusMosaicImageViewContainer.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil + cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 + + // set poll + let poll = (toot.reblog ?? toot).poll + StatusSection.configure( + cell: cell, + poll: poll, + requestUserID: requestUserID, + updateProgressAnimated: false, + timestampUpdatePublisher: timestampUpdatePublisher + ) + if let poll = poll { + ManagedObjectObserver.observe(object: poll) + .sink { _ in + // do nothing + } receiveValue: { change in + guard case let .update(object) = change.changeType, + let newPoll = object as? Poll else { return } + StatusSection.configure( + cell: cell, + poll: newPoll, + requestUserID: requestUserID, + updateProgressAnimated: true, + timestampUpdatePublisher: timestampUpdatePublisher + ) + } + .store(in: &cell.disposeBag) + } + // toolbar let replyCountTitle: String = { let count = (toot.reblog ?? toot).repliesCount?.intValue ?? 0 @@ -197,6 +227,116 @@ extension StatusSection { } .store(in: &cell.disposeBag) } + + static func configure( + cell: StatusTableViewCell, + poll: Poll?, + requestUserID: String, + updateProgressAnimated: Bool, + timestampUpdatePublisher: AnyPublisher + ) { + guard let poll = poll, + let managedObjectContext = poll.managedObjectContext else { + cell.statusView.pollTableView.isHidden = true + cell.statusView.pollStatusStackView.isHidden = true + cell.statusView.pollVoteButton.isHidden = true + return + } + + cell.statusView.pollTableView.isHidden = false + cell.statusView.pollStatusStackView.isHidden = false + cell.statusView.pollVoteCountLabel.text = { + if poll.multiple { + let count = poll.votersCount?.intValue ?? 0 + if count > 1 { + return L10n.Common.Controls.Status.Poll.VoterCount.single(count) + } else { + return L10n.Common.Controls.Status.Poll.VoterCount.multiple(count) + } + } else { + let count = poll.votesCount.intValue + if count > 1 { + return L10n.Common.Controls.Status.Poll.VoteCount.single(count) + } else { + return L10n.Common.Controls.Status.Poll.VoteCount.multiple(count) + } + } + }() + if poll.expired { + cell.pollCountdownSubscription = nil + cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.closed + } else if let expiresAt = poll.expiresAt { + cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow) + cell.pollCountdownSubscription = timestampUpdatePublisher + .sink { _ in + cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow) + } + } else { + assertionFailure() + cell.pollCountdownSubscription = nil + cell.statusView.pollCountdownLabel.text = "-" + } + + cell.statusView.pollTableView.allowsSelection = !poll.expired + + let votedOptions = poll.options.filter { option in + (option.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + } + let didVotedLocal = !votedOptions.isEmpty + let didVotedRemote = (poll.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + cell.statusView.pollVoteButton.isEnabled = didVotedLocal + cell.statusView.pollVoteButton.isHidden = !poll.multiple ? true : (didVotedRemote || poll.expired) + + cell.statusView.pollTableViewDataSource = PollSection.tableViewDiffableDataSource( + for: cell.statusView.pollTableView, + managedObjectContext: managedObjectContext + ) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + + let pollItems = poll.options + .sorted(by: { $0.index.intValue < $1.index.intValue }) + .map { option -> PollItem in + let attribute: PollItem.Attribute = { + let selectState: PollItem.Attribute.SelectState = { + // check didVotedRemote later to make the local change possible + if !votedOptions.isEmpty { + return votedOptions.contains(option) ? .on : .off + } else if poll.expired { + return .none + } else if didVotedRemote, votedOptions.isEmpty { + return .none + } else { + return .off + } + }() + let voteState: PollItem.Attribute.VoteState = { + var needsReveal: Bool + if poll.expired { + needsReveal = true + } else if didVotedRemote { + needsReveal = true + } else { + needsReveal = false + } + guard needsReveal else { return .hidden } + let percentage: Double = { + guard poll.votesCount.intValue > 0 else { return 0.0 } + return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue) + }() + let voted = votedOptions.isEmpty ? true : votedOptions.contains(option) + return .reveal(voted: voted, percentage: percentage, animated: updateProgressAnimated) + }() + return PollItem.Attribute(selectState: selectState, voteState: voteState) + }() + let option = PollItem.opion(objectID: option.objectID, attribute: attribute) + return option + } + snapshot.appendItems(pollItems, toSection: .main) + cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + } + } extension StatusSection { diff --git a/Mastodon/Extension/CALayer.swift b/Mastodon/Extension/CALayer.swift new file mode 100644 index 00000000..41ce739e --- /dev/null +++ b/Mastodon/Extension/CALayer.swift @@ -0,0 +1,51 @@ +// +// CALayer.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-2-26. +// + +import UIKit + +extension CALayer { + + func setupShadow( + color: UIColor = .black, + alpha: Float = 0.5, + x: CGFloat = 0, + y: CGFloat = 2, + blur: CGFloat = 4, + spread: CGFloat = 0, + roundedRect: CGRect? = nil, + byRoundingCorners corners: UIRectCorner? = nil, + cornerRadii: CGSize? = nil + ) { + // assert(roundedRect != .zero) + shadowColor = color.cgColor + shadowOpacity = alpha + shadowOffset = CGSize(width: x, height: y) + shadowRadius = blur / 2 + rasterizationScale = UIScreen.main.scale + shouldRasterize = true + masksToBounds = false + + guard let roundedRect = roundedRect, + let corners = corners, + let cornerRadii = cornerRadii else { + return + } + + if spread == 0 { + shadowPath = UIBezierPath(roundedRect: roundedRect, byRoundingCorners: corners, cornerRadii: cornerRadii).cgPath + } else { + let rect = roundedRect.insetBy(dx: -spread, dy: -spread) + shadowPath = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: cornerRadii).cgPath + } + } + + func removeShadow() { + shadowRadius = 0 + } + + +} diff --git a/Mastodon/Extension/UITableView.swift b/Mastodon/Extension/UITableView.swift new file mode 100644 index 00000000..22ae6c0b --- /dev/null +++ b/Mastodon/Extension/UITableView.swift @@ -0,0 +1,55 @@ +// +// UITableView.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-3-2. +// + +import UIKit + +extension UITableView { + + // static let groupedTableViewPaddingHeaderViewHeight: CGFloat = 16 + // static var groupedTableViewPaddingHeaderView: UIView { + // return UIView(frame: CGRect(x: 0, y: 0, width: 100, height: groupedTableViewPaddingHeaderViewHeight)) + // } + +} + +extension UITableView { + + func deselectRow(with transitionCoordinator: UIViewControllerTransitionCoordinator?, animated: Bool) { + guard let indexPathForSelectedRow = indexPathForSelectedRow else { return } + + guard let transitionCoordinator = transitionCoordinator else { + deselectRow(at: indexPathForSelectedRow, animated: animated) + return + } + + transitionCoordinator.animate(alongsideTransition: { _ in + self.deselectRow(at: indexPathForSelectedRow, animated: animated) + }, completion: { context in + if context.isCancelled { + self.selectRow(at: indexPathForSelectedRow, animated: animated, scrollPosition: .none) + } + }) + } + + func blinkRow(at indexPath: IndexPath) { + DispatchQueue.main.asyncAfter(wallDeadline: .now() + 1) { [weak self] in + guard let self = self else { return } + guard let cell = self.cellForRow(at: indexPath) else { return } + let backgroundColor = cell.backgroundColor + + UIView.animate(withDuration: 0.3) { + cell.backgroundColor = Asset.Colors.Label.highlight.color.withAlphaComponent(0.5) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + UIView.animate(withDuration: 0.3) { + cell.backgroundColor = backgroundColor + } + } + } + } + } + +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index d8bdb706..32786b40 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -33,6 +33,10 @@ internal enum Asset { } internal enum Colors { internal enum Background { + internal enum Poll { + internal static let disabled = ColorAsset(name: "Colors/Background/Poll/disabled") + internal static let highlight = ColorAsset(name: "Colors/Background/Poll/highlight") + } internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 035c0011..7c595918 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -13,6 +13,12 @@ internal enum L10n { internal enum Common { internal enum Alerts { + internal enum Common { + /// Please try again. + internal static let pleaseTryAgain = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgain") + /// Please try again later. + internal static let pleaseTryAgainLater = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgainLater") + } internal enum ServerError { /// Server Error internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title") @@ -21,6 +27,12 @@ internal enum L10n { /// Sign Up Failure internal static let title = L10n.tr("Localizable", "Common.Alerts.SignUpFailure.Title") } + internal enum VoteFailure { + /// The poll has expired + internal static let pollExpired = L10n.tr("Localizable", "Common.Alerts.VoteFailure.PollExpired") + /// Vote Failure + internal static let title = L10n.tr("Localizable", "Common.Alerts.VoteFailure.Title") + } } internal enum Controls { internal enum Actions { @@ -68,6 +80,36 @@ internal enum L10n { internal static func userBoosted(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) } + internal enum Poll { + /// Closed + internal static let closed = L10n.tr("Localizable", "Common.Controls.Status.Poll.Closed") + /// %@ left + internal static func timeLeft(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Poll.TimeLeft", String(describing: p1)) + } + /// Vote + internal static let vote = L10n.tr("Localizable", "Common.Controls.Status.Poll.Vote") + internal enum VoteCount { + /// %d votes + internal static func multiple(_ p1: Int) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoteCount.Multiple", p1) + } + /// %d vote + internal static func single(_ p1: Int) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoteCount.Single", p1) + } + } + internal enum VoterCount { + /// %d voters + internal static func multiple(_ p1: Int) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoterCount.Multiple", p1) + } + /// %d voter + internal static func single(_ p1: Int) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoterCount.Single", p1) + } + } + } } internal enum Timeline { /// Load More diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift new file mode 100644 index 00000000..cd4e5160 --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -0,0 +1,181 @@ +// +// StatusProvider+StatusTableViewCellDelegate.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/8. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK +import ActiveLabel + +// MARK: - ActionToolbarContainerDelegate +extension StatusTableViewCellDelegate where Self: StatusProvider { + + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) { + StatusProviderFacade.responseToStatusLikeAction(provider: self, cell: cell) + } + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) { + guard let diffableDataSource = self.tableViewDiffableDataSource else { return } + guard let item = item(for: cell, indexPath: nil) else { return } + + switch item { + case .homeTimelineIndex(_, let attribute): + attribute.isStatusTextSensitive = false + case .toot(_, let attribute): + attribute.isStatusTextSensitive = false + default: + return + } + var snapshot = diffableDataSource.snapshot() + snapshot.reloadItems([item]) + diffableDataSource.apply(snapshot) + } + +} + +// MARK: - MosciaImageViewContainerDelegate +extension StatusTableViewCellDelegate where Self: StatusProvider { + + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { + + } + + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { + guard let diffableDataSource = self.tableViewDiffableDataSource else { return } + guard let item = item(for: cell, indexPath: nil) else { return } + + switch item { + case .homeTimelineIndex(_, let attribute): + attribute.isStatusSensitive = false + case .toot(_, let attribute): + attribute.isStatusSensitive = false + default: + return + } + + var snapshot = diffableDataSource.snapshot() + snapshot.reloadItems([item]) + UIView.animate(withDuration: 0.33) { + cell.statusView.statusMosaicImageViewContainer.blurVisualEffectView.effect = nil + cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = 0.0 + } completion: { _ in + diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + } + +} + +// MARK: - PollTableView +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) + .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 } + + 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 + + button.isEnabled = false + + return self.context.apiService.vote( + domain: domain, + pollID: poll.id, + pollObjectID: poll.objectID, + choices: choices, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .switchToLatest() + .sink(receiveCompletion: { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: multiple vote fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + button.isEnabled = true + case .finished: + break + } + }, receiveValue: { response in + // do nothing + }) + .store(in: &context.disposeBag) + } + + func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + guard let activeMastodonAuthentication = context.authenticationService.activeMastodonAuthentication.value else { return } + + guard let diffableDataSource = cell.statusView.pollTableViewDataSource else { return } + let item = diffableDataSource.itemIdentifier(for: indexPath) + guard case let .opion(objectID, attribute) = item else { return } + guard let option = managedObjectContext.object(with: objectID) as? PollOption else { return } + + let poll = option.poll + let pollObjectID = option.poll.objectID + let domain = poll.toot.domain + + if poll.multiple { + var votedOptions = poll.options.filter { ($0.votedBy ?? Set()).contains(where: { $0.id == activeMastodonAuthenticationBox.userID }) } + if votedOptions.contains(option) { + votedOptions.remove(option) + } else { + votedOptions.insert(option) + } + let choices = votedOptions.map { $0.index.intValue } + context.apiService.vote( + pollObjectID: option.poll.objectID, + mastodonUserObjectID: activeMastodonAuthentication.user.objectID, + choices: choices + ) + .handleEvents(receiveOutput: { _ in + // TODO: add haptic + }) + .receive(on: DispatchQueue.main) + .sink { completion in + // Do nothing + } receiveValue: { _ in + // Do nothing + } + .store(in: &context.disposeBag) + } else { + let choices = [option.index.intValue] + context.apiService.vote( + pollObjectID: pollObjectID, + mastodonUserObjectID: activeMastodonAuthentication.user.objectID, + choices: [option.index.intValue] + ) + .handleEvents(receiveOutput: { _ in + // TODO: add haptic + }) + .flatMap { pollID -> AnyPublisher, Error> in + return self.context.apiService.vote( + domain: domain, + pollID: pollID, + pollObjectID: pollObjectID, + choices: choices, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .receive(on: DispatchQueue.main) + .sink { completion in + + } receiveValue: { response in + print(response.value) + } + .store(in: &context.disposeBag) + } + } + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift deleted file mode 100644 index 336434ff..00000000 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// StatusProvider+TimelinePostTableViewCellDelegate.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/8. -// - -import os.log -import UIKit -import Combine -import CoreData -import CoreDataStack -import MastodonSDK -import ActiveLabel - -// MARK: - ActionToolbarContainerDelegate -extension StatusTableViewCellDelegate where Self: StatusProvider { - - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) { - StatusProviderFacade.responseToStatusLikeAction(provider: self, cell: cell) - } - - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) { - guard let diffableDataSource = self.tableViewDiffableDataSource else { return } - item(for: cell, indexPath: nil) - .receive(on: DispatchQueue.main) - .sink { [weak self] item in - guard let _ = self else { return } - guard let item = item else { return } - switch item { - case .homeTimelineIndex(_, let attribute): - attribute.isStatusTextSensitive = false - case .toot(_, let attribute): - attribute.isStatusTextSensitive = false - default: - return - } - var snapshot = diffableDataSource.snapshot() - snapshot.reloadItems([item]) - diffableDataSource.apply(snapshot) - } - .store(in: &cell.disposeBag) - } - -} - -extension StatusTableViewCellDelegate where Self: StatusProvider { - - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { - - } - - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { - guard let diffableDataSource = self.tableViewDiffableDataSource else { return } - item(for: cell, indexPath: nil) - .receive(on: DispatchQueue.main) - .sink { [weak self] item in - guard let _ = self else { return } - guard let item = item else { return } - switch item { - case .homeTimelineIndex(_, let attribute): - attribute.isStatusSensitive = false - case .toot(_, let attribute): - attribute.isStatusSensitive = false - default: - return - } - - var snapshot = diffableDataSource.snapshot() - snapshot.reloadItems([item]) - UIView.animate(withDuration: 0.33) { - cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = nil - cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = 0.0 - } completion: { _ in - diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } - } - .store(in: &cell.disposeBag) - } - -} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift new file mode 100644 index 00000000..93f627c0 --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -0,0 +1,76 @@ +// +// StatusProvider+UITableViewDelegate.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import MastodonSDK + +extension StatusTableViewCellDelegate where Self: StatusProvider { + // TODO: + // func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + // } + + func handleTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + let now = Date() + var pollID: Mastodon.Entity.Poll.ID? + toot(for: cell, indexPath: indexPath) + .compactMap { [weak self] toot -> 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 } + pollID = poll.id + + // not expired AND last update > 60s + guard !poll.expired else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s expired. Skip for update", ((#file as NSString).lastPathComponent), #line, #function, poll.id) + return nil + } + let timeIntervalSinceUpdate = now.timeIntervalSince(poll.updatedAt) + #if DEBUG + let autoRefreshTimeInterval: TimeInterval = 3 // speedup testing + #else + let autoRefreshTimeInterval: TimeInterval = 60 + #endif + guard timeIntervalSinceUpdate > autoRefreshTimeInterval else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s updated in the %.2fs. Skip for update", ((#file as NSString).lastPathComponent), #line, #function, poll.id, timeIntervalSinceUpdate) + return nil + } + 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, + pollID: poll.id, + pollObjectID: poll.objectID, + mastodonAuthenticationBox: authenticationBox + ) + } + .setFailureType(to: Error.self) + .switchToLatest() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info fail to update: %s", ((#file as NSString).lastPathComponent), #line, #function, pollID ?? "?", error.localizedDescription) + case .finished: + break + } + }, receiveValue: { response in + let poll = response.value + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info updated", ((#file as NSString).lastPathComponent), #line, #function, poll.id) + }) + .store(in: &disposeBag) + } + +} + +extension StatusTableViewCellDelegate where Self: StatusProvider { + + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider.swift b/Mastodon/Protocol/StatusProvider/StatusProvider.swift index 781ccc9f..a0a7116f 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider.swift @@ -7,13 +7,17 @@ import UIKit import Combine +import CoreData 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 + // sync + var managedObjectContext: NSManagedObjectContext { get } var tableViewDiffableDataSource: UITableViewDiffableDataSource? { get } - func item(for cell: UITableViewCell, indexPath: IndexPath?) -> Future + func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? } diff --git a/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift new file mode 100644 index 00000000..1b035008 --- /dev/null +++ b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift @@ -0,0 +1,12 @@ +// +// TableViewCellHeightCacheableContainer.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import UIKit + +protocol TableViewCellHeightCacheableContainer: UIViewController { + // TODO: +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/Contents.json new file mode 100644 index 00000000..6e965652 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/disabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/disabled.colorset/Contents.json new file mode 100644 index 00000000..78cde95f --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/disabled.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.784", + "green" : "0.682", + "red" : "0.608" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/highlight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/highlight.colorset/Contents.json new file mode 100644 index 00000000..2e1ce5f3 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/highlight.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.851", + "green" : "0.565", + "red" : "0.169" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json index 7e037593..91dac809 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json @@ -5,9 +5,27 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x37", - "green" : "0x2D", - "red" : "0x29" + "blue" : "0xE8", + "green" : "0xE1", + "red" : "0xD9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.216", + "green" : "0.176", + "red" : "0.161" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json index edc0dce9..d097fec4 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "232", - "green" : "225", - "red" : "217" + "blue" : "0xE8", + "green" : "0xE1", + "red" : "0xD9" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json index 70b1446d..8953c8fb 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.600", - "blue" : "0x43", - "green" : "0x3C", - "red" : "0x3C" + "blue" : "67", + "green" : "60", + "red" : "60" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json index 5fb782c4..ba375b79 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.600", - "blue" : "0.263", - "green" : "0.235", - "red" : "0.235" + "blue" : "67", + "green" : "60", + "red" : "60" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 4cf8ea52..54b69e27 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -1,5 +1,9 @@ +"Common.Alerts.Common.PleaseTryAgain" = "Please try again."; +"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; "Common.Alerts.ServerError.Title" = "Server Error"; "Common.Alerts.SignUpFailure.Title" = "Sign Up Failure"; +"Common.Alerts.VoteFailure.PollExpired" = "The poll has expired"; +"Common.Alerts.VoteFailure.Title" = "Vote Failure"; "Common.Controls.Actions.Add" = "Add"; "Common.Controls.Actions.Back" = "Back"; "Common.Controls.Actions.Cancel" = "Cancel"; @@ -17,6 +21,13 @@ "Common.Controls.Actions.SignUp" = "Sign Up"; "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; +"Common.Controls.Status.Poll.Closed" = "Closed"; +"Common.Controls.Status.Poll.TimeLeft" = "%@ left"; +"Common.Controls.Status.Poll.Vote" = "Vote"; +"Common.Controls.Status.Poll.VoteCount.Multiple" = "%d votes"; +"Common.Controls.Status.Poll.VoteCount.Single" = "%d vote"; +"Common.Controls.Status.Poll.VoterCount.Multiple" = "%d voters"; +"Common.Controls.Status.Poll.VoterCount.Single" = "%d voter"; "Common.Controls.Status.ShowPost" = "Show Post"; "Common.Controls.Status.StatusContentWarning" = "content warning"; "Common.Controls.Status.UserBoosted" = "%@ boosted"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 69f0347e..0937e1fb 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -7,6 +7,8 @@ import os.log import UIKit +import CoreData +import CoreDataStack #if DEBUG extension HomeTimelineViewController { @@ -17,6 +19,8 @@ extension HomeTimelineViewController { identifier: nil, options: .displayInline, children: [ + moveMenu, + dropMenu, UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in guard let self = self else { return } self.showPublicTimelineAction(action) @@ -29,10 +33,136 @@ extension HomeTimelineViewController { ) return menu } + + var moveMenu: UIMenu { + return UIMenu( + title: "Move toโ€ฆ", + image: UIImage(systemName: "arrow.forward.circle"), + identifier: nil, + options: [], + children: [ + UIAction(title: "First Gap", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToTopGapAction(action) + }), + UIAction(title: "First Poll Toot", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstPollToot(action) + }), +// UIAction(title: "First Reply Toot", image: nil, attributes: [], handler: { [weak self] action in +// guard let self = self else { return } +// self.moveToFirstReplyToot(action) +// }), +// UIAction(title: "First Reply Reblog", image: nil, attributes: [], handler: { [weak self] action in +// guard let self = self else { return } +// self.moveToFirstReplyReblog(action) +// }), +// UIAction(title: "First Video Toot", image: nil, attributes: [], handler: { [weak self] action in +// guard let self = self else { return } +// self.moveToFirstVideoToot(action) +// }), +// UIAction(title: "First GIF Toot", image: nil, attributes: [], handler: { [weak self] action in +// guard let self = self else { return } +// self.moveToFirstGIFToot(action) +// }), + ] + ) + } + + var dropMenu: UIMenu { + return UIMenu( + title: "Dropโ€ฆ", + image: UIImage(systemName: "minus.circle"), + identifier: nil, + options: [], + children: [50, 100, 150, 200, 250, 300].map { count in + UIAction(title: "Drop Recent \(count) Toots", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.dropRecentTootsAction(action, count: count) + }) + } + ) + } } extension HomeTimelineViewController { + @objc private func moveToTopGapAction(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeMiddleLoader: return true + default: return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + } + } + + @objc private func moveToFirstPollToot(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + 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.poll != nil + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found poll toot") + } + } + + @objc private func dropRecentTootsAction(_ sender: UIAction, count: Int) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + + let droppingObjectIDs = snapshotTransitioning.itemIdentifiers.prefix(count).compactMap { item -> NSManagedObjectID? in + switch item { + case .homeTimelineIndex(let objectID, _): return objectID + default: return nil + } + } + var droppingTootObjectIDs: [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) + self.context.apiService.backgroundManagedObjectContext.delete(homeTimelineIndex) + } + } + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .success: + self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in + guard let self = self else { return } + for objectID in droppingTootObjectIDs { + guard let toot = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Toot else { continue } + self.context.apiService.backgroundManagedObjectContext.delete(toot) + } + } + .sink { _ in + // do nothing + } + .store(in: &self.disposeBag) + case .failure(let error): + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } + @objc private func showPublicTimelineAction(_ sender: UIAction) { coordinator.present(scene: .publicTimeline, from: self, transition: .show) } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift index 69782007..a0d9204b 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift @@ -8,6 +8,7 @@ import os.log import UIKit import Combine +import CoreData import CoreDataStack // MARK: - StatusProvider @@ -47,25 +48,26 @@ extension HomeTimelineViewController: StatusProvider { return Future { promise in promise(.success(nil)) } } + var managedObjectContext: NSManagedObjectContext { + return viewModel.fetchedResultsController.managedObjectContext + } + var tableViewDiffableDataSource: UITableViewDiffableDataSource? { return viewModel.diffableDataSource } - func item(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 - } - - promise(.success(item)) + 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 } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index d3906fd9..b9d0f94e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -106,7 +106,7 @@ extension HomeTimelineViewController { viewModel.setupDiffableDataSource( for: tableView, dependency: self, - timelinePostTableViewCellDelegate: self, + statusTableViewCellDelegate: self, timelineMiddleLoaderTableViewCellDelegate: self ) @@ -220,16 +220,21 @@ extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer { // MARK: - UITableViewDelegate extension HomeTimelineViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } - - guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { - return 200 - } - // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) - - return ceil(frame.height) + // TODO: + // func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + // guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } + // guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } + // + // guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { + // return 200 + // } + // // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) + // + // return ceil(frame.height) + // } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index d5345de4..4a34b922 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -15,7 +15,7 @@ extension HomeTimelineViewModel { func setupDiffableDataSource( for tableView: UITableView, dependency: NeedsDependency, - timelinePostTableViewCellDelegate: StatusTableViewCellDelegate, + statusTableViewCellDelegate: StatusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate ) { let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) @@ -28,7 +28,7 @@ extension HomeTimelineViewModel { dependency: dependency, managedObjectContext: fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, - timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate, + statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate ) } @@ -73,7 +73,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { // that's will be the most fastest fetch because of upstream just update and no modify needs consider - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusTimelineAttribute] = [:] + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] for item in oldSnapshot.itemIdentifiers { guard case let .homeTimelineIndex(objectID, attribute) = item else { continue } @@ -88,7 +88,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { guard let spoilerText = toot.spoilerText, !spoilerText.isEmpty else { return false } return true }() - let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: toot.sensitive) + let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: toot.sensitive) // append new item into snapshot newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute)) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index dd5ee97b..44457839 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -110,10 +110,10 @@ final class HomeTimelineViewModel: NSObject { context.authenticationService.activeMastodonAuthentication .sink { [weak self] activeMastodonAuthentication in guard let self = self else { return } - guard let twitterAuthentication = activeMastodonAuthentication else { return } - let activeTwitterUserID = twitterAuthentication.userID + guard let mastodonAuthentication = activeMastodonAuthentication else { return } + let activeMastodonUserID = mastodonAuthentication.userID let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - HomeTimelineIndex.predicate(userID: activeTwitterUserID), + HomeTimelineIndex.predicate(userID: activeMastodonUserID), HomeTimelineIndex.notDeleted() ]) self.timelinePredicate.value = predicate diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift index 6d83e79a..aceb8371 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift @@ -8,12 +8,13 @@ import os.log import UIKit import Combine +import CoreData import CoreDataStack import MastodonSDK // MARK: - StatusProvider extension PublicTimelineViewController: StatusProvider { - + func toot() -> Future { return Future { promise in promise(.success(nil)) } } @@ -48,25 +49,25 @@ extension PublicTimelineViewController: StatusProvider { return Future { promise in promise(.success(nil)) } } + var managedObjectContext: NSManagedObjectContext { + return viewModel.fetchedResultsController.managedObjectContext + } + var tableViewDiffableDataSource: UITableViewDiffableDataSource? { return viewModel.diffableDataSource } - func item(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 - } - - promise(.success(item)) + 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 } } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index dd5ffc84..98d2dbd9 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -76,7 +76,7 @@ extension PublicTimelineViewController { viewModel.setupDiffableDataSource( for: tableView, dependency: self, - timelinePostTableViewCellDelegate: self, + statusTableViewCellDelegate: self, timelineMiddleLoaderTableViewCellDelegate: self ) } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index f9c92fa0..d69da8f6 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -14,7 +14,7 @@ extension PublicTimelineViewModel { func setupDiffableDataSource( for tableView: UITableView, dependency: NeedsDependency, - timelinePostTableViewCellDelegate: StatusTableViewCellDelegate, + statusTableViewCellDelegate: StatusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate ) { let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) @@ -27,7 +27,7 @@ extension PublicTimelineViewModel { dependency: dependency, managedObjectContext: fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, - timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate, + statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate ) items.value = [] @@ -50,7 +50,7 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { return indexes.firstIndex(of: toot.id).map { index in (index, toot) } } .sorted { $0.0 < $1.0 } - var oldSnapshotAttributeDict: [NSManagedObjectID: Item.StatusTimelineAttribute] = [:] + var oldSnapshotAttributeDict: [NSManagedObjectID: Item.StatusAttribute] = [:] for item in self.items.value { guard case let .toot(objectID, attribute) = item else { continue } oldSnapshotAttributeDict[objectID] = attribute @@ -63,7 +63,7 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { guard let spoilerText = targetToot.spoilerText, !spoilerText.isEmpty else { return false } return true }() - let attribute = oldSnapshotAttributeDict[toot.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetToot.sensitive) + 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)) diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index be754ed8..c1f3cb3d 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -13,16 +13,21 @@ import AlamofireImage protocol StatusViewDelegate: class { func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) + func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) } final class StatusView: UIView { + var statusPollTableViewHeightObservation: NSKeyValueObservation? + static let avatarImageSize = CGSize(width: 42, height: 42) static let avatarImageCornerRadius: CGFloat = 4 static let contentWarningBlurRadius: CGFloat = 12 weak var delegate: StatusViewDelegate? var isStatusTextSensitive = false + var pollTableViewDataSource: UITableViewDiffableDataSource? + var pollTableViewHeightLaoutConstraint: NSLayoutConstraint! let headerContainerStackView = UIStackView() @@ -99,7 +104,49 @@ final class StatusView: UIView { button.setTitle(L10n.Common.Controls.Status.showPost, for: .normal) return button }() - let statusMosaicImageView = MosaicImageViewContainer() + let statusMosaicImageViewContainer = MosaicImageViewContainer() + + let pollTableView: PollTableView = { + let tableView = PollTableView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + tableView.register(PollOptionTableViewCell.self, forCellReuseIdentifier: String(describing: PollOptionTableViewCell.self)) + tableView.isScrollEnabled = false + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + return tableView + }() + + let pollStatusStackView = UIStackView() + let pollVoteCountLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular)) + label.textColor = Asset.Colors.Label.secondary.color + label.text = L10n.Common.Controls.Status.Poll.VoteCount.single(0) + return label + }() + let pollStatusDotLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular)) + label.textColor = Asset.Colors.Label.secondary.color + label.text = " ยท " + return label + }() + let pollCountdownLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular)) + label.textColor = Asset.Colors.Label.secondary.color + label.text = L10n.Common.Controls.Status.Poll.timeLeft("6 hours") + return label + }() + let pollVoteButton: UIButton = { + let button = HitTestExpandedButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold)) + button.setTitle(L10n.Common.Controls.Status.Poll.vote, for: .normal) + button.setTitleColor(Asset.Colors.Button.highlight.color, for: .normal) + button.setTitleColor(Asset.Colors.Button.highlight.color.withAlphaComponent(0.8), for: .highlighted) + button.setTitleColor(Asset.Colors.Button.disabled.color, for: .disabled) + button.isEnabled = false + return button + }() // do not use visual effect view due to we blur text only without background let contentWarningBlurContentImageView: UIImageView = { @@ -136,6 +183,10 @@ final class StatusView: UIView { drawContentWarningImageView() } } + + deinit { + statusPollTableViewHeightObservation = nil + } } @@ -222,7 +273,7 @@ extension StatusView { subtitleContainerStackView.axis = .horizontal subtitleContainerStackView.addArrangedSubview(usernameLabel) - // status container: [status | image / video | audio] + // status container: [status | image / video | audio | poll | poll status] containerStackView.addArrangedSubview(statusContainerStackView) statusContainerStackView.axis = .vertical statusContainerStackView.spacing = 10 @@ -236,6 +287,7 @@ extension StatusView { activeTextLabel.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), statusTextContainerView.bottomAnchor.constraint(greaterThanOrEqualTo: activeTextLabel.bottomAnchor), ]) + activeTextLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) contentWarningBlurContentImageView.translatesAutoresizingMaskIntoConstraints = false statusTextContainerView.addSubview(contentWarningBlurContentImageView) NSLayoutConstraint.activate([ @@ -257,20 +309,50 @@ extension StatusView { ]) statusContentWarningContainerStackView.addArrangedSubview(contentWarningTitle) statusContentWarningContainerStackView.addArrangedSubview(contentWarningActionButton) - statusContainerStackView.addArrangedSubview(statusMosaicImageView) + statusContainerStackView.addArrangedSubview(statusMosaicImageViewContainer) + pollTableView.translatesAutoresizingMaskIntoConstraints = false + statusContainerStackView.addArrangedSubview(pollTableView) + pollTableViewHeightLaoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1) + NSLayoutConstraint.activate([ + pollTableViewHeightLaoutConstraint, + ]) + + statusPollTableViewHeightObservation = pollTableView.observe(\.contentSize, options: .new, changeHandler: { [weak self] tableView, _ in + guard let self = self else { return } + guard self.pollTableView.contentSize.height != .zero else { + self.pollTableViewHeightLaoutConstraint.constant = 44 + return + } + self.pollTableViewHeightLaoutConstraint.constant = self.pollTableView.contentSize.height + }) + + statusContainerStackView.addArrangedSubview(pollStatusStackView) + pollStatusStackView.axis = .horizontal + pollStatusStackView.addArrangedSubview(pollVoteCountLabel) + pollStatusStackView.addArrangedSubview(pollStatusDotLabel) + pollStatusStackView.addArrangedSubview(pollCountdownLabel) + pollStatusStackView.addArrangedSubview(pollVoteButton) + pollVoteCountLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal) + pollStatusDotLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) + pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) headerContainerStackView.isHidden = true - statusMosaicImageView.isHidden = true + statusMosaicImageViewContainer.isHidden = true + pollTableView.isHidden = true + pollStatusStackView.isHidden = true + contentWarningBlurContentImageView.isHidden = true statusContentWarningContainerStackView.isHidden = true statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false contentWarningActionButton.addTarget(self, action: #selector(StatusView.contentWarningActionButtonPressed(_:)), for: .touchUpInside) + pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside) } } @@ -306,20 +388,26 @@ extension StatusView { } extension StatusView { + @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) } + + @objc private func pollVoteButtonPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.statusView(self, pollVoteButtonPressed: sender) + } + } +// MARK: - AvatarConfigurableView extension StatusView: AvatarConfigurableView { static var configurableAvatarImageSize: CGSize { return Self.avatarImageSize } static var configurableAvatarImageCornerRadius: CGFloat { return 4 } var configurableAvatarImageView: UIImageView? { return nil } var configurableAvatarButton: UIButton? { return avatarButton } var configurableVerifiedBadgeImageView: UIImageView? { nil } - - } #if canImport(SwiftUI) && DEBUG @@ -357,11 +445,11 @@ struct StatusView_Previews: PreviewProvider { statusView.drawContentWarningImageView() statusView.updateContentWarningDisplay(isHidden: false) let images = MosaicImageView_Previews.images - let imageViews = statusView.statusMosaicImageView.setupImageViews(count: 4, maxHeight: 162) + let imageViews = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) for (i, imageView) in imageViews.enumerated() { imageView.image = images[i] } - statusView.statusMosaicImageView.isHidden = false + statusView.statusMosaicImageViewContainer.isHidden = false return statusView } .previewLayout(.fixed(width: 375, height: 380)) diff --git a/Mastodon/Scene/Share/View/Control/StripProgressView.swift b/Mastodon/Scene/Share/View/Control/StripProgressView.swift new file mode 100644 index 00000000..710d8567 --- /dev/null +++ b/Mastodon/Scene/Share/View/Control/StripProgressView.swift @@ -0,0 +1,174 @@ +// +// StripProgressView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import os.log +import UIKit +import Combine + +private final class StripProgressLayer: CALayer { + + static let progressAnimationKey = "progressAnimationKey" + static let progressKey = "progress" + + var tintColor: UIColor = .black + @NSManaged var progress: CGFloat + + override class func needsDisplay(forKey key: String) -> Bool { + switch key { + case StripProgressLayer.progressKey: + return true + default: + return super.needsDisplay(forKey: key) + } + } + + override func display() { + let progress: CGFloat = { + guard animation(forKey: StripProgressLayer.progressAnimationKey) != nil else { + return self.progress + } + + return presentation()?.progress ?? self.progress + }() + // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) + + UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0) + guard let context = UIGraphicsGetCurrentContext() else { + assertionFailure() + return + } + context.clear(bounds) + + var rect = bounds + let newWidth = CGFloat(progress) * rect.width + let widthChanged = rect.width - newWidth + rect.size.width = newWidth + switch UIApplication.shared.userInterfaceLayoutDirection { + case .rightToLeft: + rect.origin.x += widthChanged + default: + break + } + let path = UIBezierPath(rect: rect) + context.setFillColor(tintColor.cgColor) + context.addPath(path.cgPath) + context.fillPath() + + contents = UIGraphicsGetImageFromCurrentImageContext()?.cgImage + UIGraphicsEndImageContext() + } + +} + +final class StripProgressView: UIView { + + var disposeBag = Set() + + private let stripProgressLayer: StripProgressLayer = { + let layer = StripProgressLayer() + return layer + }() + + override var tintColor: UIColor! { + didSet { + stripProgressLayer.tintColor = tintColor + setNeedsDisplay() + } + } + + func setProgress(_ progress: CGFloat, animated: Bool) { + stripProgressLayer.removeAnimation(forKey: StripProgressLayer.progressAnimationKey) + if animated { + let animation = CABasicAnimation(keyPath: StripProgressLayer.progressKey) + animation.fromValue = stripProgressLayer.presentation()?.progress ?? stripProgressLayer.progress + animation.toValue = progress + animation.duration = 0.33 + animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + animation.isRemovedOnCompletion = true + stripProgressLayer.add(animation, forKey: StripProgressLayer.progressAnimationKey) + stripProgressLayer.progress = progress + } else { + stripProgressLayer.progress = progress + stripProgressLayer.setNeedsDisplay() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension StripProgressView { + + private func _init() { + layer.addSublayer(stripProgressLayer) + updateLayerPath() + } + + override func layoutSubviews() { + super.layoutSubviews() + updateLayerPath() + } + +} + +extension StripProgressView { + private func updateLayerPath() { + guard bounds != .zero else { return } + + stripProgressLayer.frame = bounds + stripProgressLayer.tintColor = tintColor + stripProgressLayer.setNeedsDisplay() + } +} + +#if DEBUG +import SwiftUI + +struct VoteProgressStripView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview() { + StripProgressView() + } + .frame(width: 100, height: 44) + .padding() + .background(Color.black) + .previewLayout(.sizeThatFits) + UIViewPreview() { + let bar = StripProgressView() + bar.tintColor = .white + bar.setProgress(0.5, animated: false) + return bar + } + .frame(width: 100, height: 44) + .padding() + .background(Color.black) + .previewLayout(.sizeThatFits) + UIViewPreview() { + let bar = StripProgressView() + bar.tintColor = .white + bar.setProgress(1.0, animated: false) + return bar + } + .frame(width: 100, height: 44) + .padding() + .background(Color.black) + .previewLayout(.sizeThatFits) + } + } + +} +#endif diff --git a/Mastodon/Scene/Share/View/TableView/PollTableView.swift b/Mastodon/Scene/Share/View/TableView/PollTableView.swift new file mode 100644 index 00000000..d90be2b0 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableView/PollTableView.swift @@ -0,0 +1,10 @@ +// +// PollTableView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import UIKit + +final class PollTableView: UITableView { } diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift new file mode 100644 index 00000000..7aa7ef41 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -0,0 +1,261 @@ +// +// PollOptionTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-2-25. +// + +import UIKit +import Combine + +final class PollOptionTableViewCell: UITableViewCell { + + static let height: CGFloat = optionHeight + 2 * verticalMargin + static let optionHeight: CGFloat = 44 + static let verticalMargin: CGFloat = 5 + static let checkmarkImageSize = CGSize(width: 26, height: 26) + + private var viewStateDisposeBag = Set() + var attribute: PollItem.Attribute? + + let roundedBackgroundView = UIView() + let voteProgressStripView: StripProgressView = { + let view = StripProgressView() + view.tintColor = Asset.Colors.Background.Poll.highlight.color + return view + }() + + let checkmarkBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = .systemBackground + return view + }() + + let checkmarkImageView: UIView = { + let imageView = UIImageView() + let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .bold))! + imageView.image = image.withRenderingMode(.alwaysTemplate) + imageView.tintColor = Asset.Colors.Button.highlight.color + return imageView + }() + + let optionLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .medium) + label.textColor = Asset.Colors.Label.primary.color + label.text = "Option" + label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .left : .right + return label + }() + + let optionLabelMiddlePaddingView = UIView() + + let optionPercentageLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = Asset.Colors.Label.primary.color + label.text = "50%" + label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left + return label + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + guard let voteState = attribute?.voteState else { return } + switch voteState { + case .hidden: + let color = Asset.Colors.Background.systemGroupedBackground.color + self.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color + case .reveal: + break + } + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + guard let voteState = attribute?.voteState else { return } + switch voteState { + case .hidden: + let color = Asset.Colors.Background.systemGroupedBackground.color + self.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color + case .reveal: + break + } + } + +} + +extension PollOptionTableViewCell { + + private func _init() { + selectionStyle = .none + backgroundColor = .clear + roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(roundedBackgroundView) + NSLayoutConstraint.activate([ + roundedBackgroundView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5), + roundedBackgroundView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + roundedBackgroundView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor, constant: 5), + roundedBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionTableViewCell.optionHeight).priority(.defaultHigh), + ]) + + voteProgressStripView.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(voteProgressStripView) + NSLayoutConstraint.activate([ + voteProgressStripView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor), + voteProgressStripView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor), + voteProgressStripView.trailingAnchor.constraint(equalTo: roundedBackgroundView.trailingAnchor), + voteProgressStripView.bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor), + ]) + + checkmarkBackgroundView.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(checkmarkBackgroundView) + NSLayoutConstraint.activate([ + checkmarkBackgroundView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor, constant: 9), + checkmarkBackgroundView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor, constant: 9), + roundedBackgroundView.bottomAnchor.constraint(equalTo: checkmarkBackgroundView.bottomAnchor, constant: 9), + checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: PollOptionTableViewCell.checkmarkImageSize.width).priority(.defaultHigh), + checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionTableViewCell.checkmarkImageSize.height).priority(.defaultHigh), + ]) + + checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false + checkmarkBackgroundView.addSubview(checkmarkImageView) + NSLayoutConstraint.activate([ + checkmarkImageView.topAnchor.constraint(equalTo: checkmarkBackgroundView.topAnchor, constant: 5), + checkmarkImageView.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.leadingAnchor, constant: 5), + checkmarkBackgroundView.trailingAnchor.constraint(equalTo: checkmarkImageView.trailingAnchor, constant: 5), + checkmarkBackgroundView.bottomAnchor.constraint(equalTo: checkmarkImageView.bottomAnchor, constant: 5), + ]) + + optionLabel.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(optionLabel) + NSLayoutConstraint.activate([ + optionLabel.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.trailingAnchor, constant: 14), + optionLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), + ]) + + optionLabelMiddlePaddingView.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(optionLabelMiddlePaddingView) + NSLayoutConstraint.activate([ + optionLabelMiddlePaddingView.leadingAnchor.constraint(equalTo: optionLabel.trailingAnchor), + optionLabelMiddlePaddingView.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), + optionLabelMiddlePaddingView.heightAnchor.constraint(equalToConstant: 4).priority(.defaultHigh), + optionLabelMiddlePaddingView.widthAnchor.constraint(greaterThanOrEqualToConstant: 8).priority(.defaultLow), + ]) + optionLabelMiddlePaddingView.setContentHuggingPriority(.defaultLow, for: .horizontal) + + optionPercentageLabel.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(optionPercentageLabel) + NSLayoutConstraint.activate([ + optionPercentageLabel.leadingAnchor.constraint(equalTo: optionLabelMiddlePaddingView.trailingAnchor), + roundedBackgroundView.trailingAnchor.constraint(equalTo: optionPercentageLabel.trailingAnchor, constant: 18), + optionPercentageLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), + ]) + optionPercentageLabel.setContentHuggingPriority(.required - 1, for: .horizontal) + optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + } + + override func layoutSubviews() { + super.layoutSubviews() + + updateCornerRadius() + updateTextAppearance() + } + + private func updateCornerRadius() { + roundedBackgroundView.layer.masksToBounds = true + roundedBackgroundView.layer.cornerRadius = PollOptionTableViewCell.optionHeight * 0.5 + roundedBackgroundView.layer.cornerCurve = .circular + + checkmarkBackgroundView.layer.masksToBounds = true + checkmarkBackgroundView.layer.cornerRadius = PollOptionTableViewCell.checkmarkImageSize.width * 0.5 + checkmarkBackgroundView.layer.cornerCurve = .circular + } + + func updateTextAppearance() { + guard let voteState = attribute?.voteState else { + optionLabel.textColor = Asset.Colors.Label.primary.color + optionLabel.layer.removeShadow() + return + } + + switch voteState { + case .hidden: + optionLabel.textColor = Asset.Colors.Label.primary.color + optionLabel.layer.removeShadow() + case .reveal(_, let percentage, _): + if CGFloat(percentage) * voteProgressStripView.frame.width > optionLabelMiddlePaddingView.frame.minX { + optionLabel.textColor = .white + optionLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) + } else { + optionLabel.textColor = Asset.Colors.Label.primary.color + optionLabel.layer.removeShadow() + } + + if CGFloat(percentage) * voteProgressStripView.frame.width > optionLabelMiddlePaddingView.frame.maxX { + optionPercentageLabel.textColor = .white + optionPercentageLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) + } else { + optionPercentageLabel.textColor = Asset.Colors.Label.primary.color + optionPercentageLabel.layer.removeShadow() + } + } + + } + +} + + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct PollTableViewCell_Previews: PreviewProvider { + + static var controls: some View { + Group { + UIViewPreview() { + PollOptionTableViewCell() + } + .previewLayout(.fixed(width: 375, height: 44 + 10)) + UIViewPreview() { + let cell = PollOptionTableViewCell() + PollSection.configure(cell: cell, selectState: .off) + return cell + } + .previewLayout(.fixed(width: 375, height: 44 + 10)) + UIViewPreview() { + let cell = PollOptionTableViewCell() + PollSection.configure(cell: cell, selectState: .on) + return cell + } + .previewLayout(.fixed(width: 375, height: 44 + 10)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 572f23e0..13c3afba 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -9,14 +9,20 @@ import os.log import UIKit import AVKit import Combine - +import CoreData +import CoreDataStack protocol StatusTableViewCellDelegate: class { - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) + var context: AppContext! { get } + var managedObjectContext: NSManagedObjectContext { get } + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) - + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) + func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) } final class StatusTableViewCell: UITableViewCell { @@ -26,6 +32,7 @@ final class StatusTableViewCell: UITableViewCell { weak var delegate: StatusTableViewCellDelegate? var disposeBag = Set() + var pollCountdownSubscription: AnyCancellable? var observations = Set() let statusView = StatusView() @@ -34,6 +41,7 @@ final class StatusTableViewCell: UITableViewCell { super.prepareForReuse() statusView.isStatusTextSensitive = false statusView.cleanUpContentWarning() + statusView.pollTableView.dataSource = nil disposeBag.removeAll() observations.removeAll() } @@ -85,17 +93,100 @@ extension StatusTableViewCell { bottomPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color statusView.delegate = self - statusView.statusMosaicImageView.delegate = self + statusView.pollTableView.delegate = self + statusView.statusMosaicImageViewContainer.delegate = self statusView.actionToolbarContainer.delegate = self } } +// MARK: - UITableViewDelegate +extension StatusTableViewCell: UITableViewDelegate { + + func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { + if tableView === statusView.pollTableView, let diffableDataSource = statusView.pollTableViewDataSource { + var pollID: String? + defer { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s. PollID: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription, pollID ?? "") + } + guard let item = diffableDataSource.itemIdentifier(for: indexPath), + case let .opion(objectID, _) = item, + let option = delegate?.managedObjectContext.object(with: objectID) as? PollOption else { + return false + } + pollID = option.poll.id + return !option.poll.expired + } else { + assertionFailure() + return true + } + } + + func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + if tableView === statusView.pollTableView, let diffableDataSource = statusView.pollTableViewDataSource { + var pollID: String? + defer { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s. PollID: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription, pollID ?? "") + } + + guard let context = delegate?.context else { return nil } + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil } + guard let item = diffableDataSource.itemIdentifier(for: indexPath), + case let .opion(objectID, _) = item, + let option = delegate?.managedObjectContext.object(with: objectID) as? PollOption else { + return nil + } + let poll = option.poll + pollID = poll.id + + // disallow select when: poll expired OR user voted remote OR user voted local + let userID = activeMastodonAuthenticationBox.userID + let didVotedRemote = (option.poll.votedBy ?? Set()).contains(where: { $0.id == userID }) + let votedOptions = poll.options.filter { option in + (option.votedBy ?? Set()).map { $0.id }.contains(userID) + } + let didVotedLocal = !votedOptions.isEmpty + + if poll.multiple { + guard !option.poll.expired, !didVotedRemote else { + return nil + } + } else { + guard !option.poll.expired, !didVotedRemote, !didVotedLocal else { + return nil + } + } + + return indexPath + } else { + assertionFailure() + return indexPath + } + } + + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if tableView === statusView.pollTableView { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + delegate?.statusTableViewCell(self, pollTableView: statusView.pollTableView, didSelectRowAt: indexPath) + } else { + assertionFailure() + } + } + +} + // MARK: - StatusViewDelegate extension StatusTableViewCell: StatusViewDelegate { + func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) { delegate?.statusTableViewCell(self, statusView: statusView, contentWarningActionButtonPressed: button) } + + func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) { + delegate?.statusTableViewCell(self, statusView: statusView, pollVoteButtonPressed: button) + } + } // MARK: - MosaicImageViewDelegate diff --git a/Mastodon/Service/APIService/APIService+APIError.swift b/Mastodon/Service/APIService/APIService+APIError.swift index 7fd29b6b..37235c2c 100644 --- a/Mastodon/Service/APIService/APIService+APIError.swift +++ b/Mastodon/Service/APIService/APIService+APIError.swift @@ -21,6 +21,8 @@ extension APIService { case badResponse case requestThrottle + case voteExpiredPoll + // Server API error case mastodonAPIError(Mastodon.API.Error) } @@ -44,6 +46,7 @@ extension APIService.APIError: LocalizedError { case .badRequest: return "Bad Request" case .badResponse: return "Bad Response" case .requestThrottle: return "Request Throttled" + case .voteExpiredPoll: return L10n.Common.Alerts.VoteFailure.title case .mastodonAPIError(let error): guard let responseError = error.mastodonError else { guard error.httpResponseStatus != .ok else { @@ -62,6 +65,7 @@ extension APIService.APIError: LocalizedError { case .badRequest: return "Request invalid." case .badResponse: return "Response invalid." case .requestThrottle: return "Request too frequency." + case .voteExpiredPoll: return L10n.Common.Alerts.VoteFailure.pollExpired case .mastodonAPIError(let error): guard let responseError = error.mastodonError else { return nil @@ -73,9 +77,10 @@ extension APIService.APIError: LocalizedError { var helpAnchor: String? { switch errorReason { case .authenticationMissing: return "Please request after authenticated." - case .badRequest: return "Please try again." - case .badResponse: return "Please try again." - case .requestThrottle: return "Please try again later." + case .badRequest: return L10n.Common.Alerts.Common.pleaseTryAgain + case .badResponse: return L10n.Common.Alerts.Common.pleaseTryAgain + case .requestThrottle: return L10n.Common.Alerts.Common.pleaseTryAgainLater + case .voteExpiredPoll: return nil case .mastodonAPIError(let error): guard let responseError = error.mastodonError else { return nil diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index 34bd3f0e..e1d5febe 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -94,7 +94,7 @@ extension APIService { assertionFailure() return } - APIService.CoreData.mergeToot(for: requestMastodonUser, old: oldToot, in: mastodonAuthenticationBox.domain, entity: entity, networkDate: response.networkDate) + APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) 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 ) } .setFailureType(to: Error.self) @@ -132,7 +132,7 @@ extension APIService { let requestMastodonUserID = mastodonAuthenticationBox.userID let query = Mastodon.API.Favorites.ListQuery(limit: limit, minID: nil, maxID: maxID) - return Mastodon.API.Favorites.getFavoriteStatus(domain: mastodonAuthenticationBox.domain, session: session, authorization: mastodonAuthenticationBox.userAuthorization, query: query) + return Mastodon.API.Favorites.favoritedStatus(domain: mastodonAuthenticationBox.domain, session: session, authorization: mastodonAuthenticationBox.userAuthorization, query: query) .map { response -> AnyPublisher, Error> in let log = OSLog.api diff --git a/Mastodon/Service/APIService/APIService+Poll.swift b/Mastodon/Service/APIService/APIService+Poll.swift new file mode 100644 index 00000000..0b240466 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Poll.swift @@ -0,0 +1,197 @@ +// +// APIService+Poll.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func poll( + domain: String, + pollID: Mastodon.Entity.Poll.ID, + pollObjectID: NSManagedObjectID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + let requestMastodonUserID = mastodonAuthenticationBox.userID + + return Mastodon.API.Polls.poll( + session: session, + domain: domain, + pollID: pollID, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let entity = response.value + let managedObjectContext = self.backgroundManagedObjectContext + + return managedObjectContext.performChanges { + let _requestMastodonUser: MastodonUser? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + guard let requestMastodonUser = _requestMastodonUser else { + assertionFailure() + return + } + guard let poll = managedObjectContext.object(with: pollObjectID) as? Poll else { return } + APIService.CoreData.merge(poll: poll, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} + +extension APIService { + + /// vote local + /// # Note + /// Not mark the poll voted so that view model could know when to reveal the results + func vote( + pollObjectID: NSManagedObjectID, + mastodonUserObjectID: NSManagedObjectID, + choices: [Int] + ) -> AnyPublisher { + var _targetPollID: Mastodon.Entity.Poll.ID? + var isPollExpired = false + var didVotedLocal = false + + let managedObjectContext = backgroundManagedObjectContext + return managedObjectContext.performChanges { + let poll = managedObjectContext.object(with: pollObjectID) as! Poll + let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + + _targetPollID = poll.id + + if let expiresAt = poll.expiresAt, Date().timeIntervalSince(expiresAt) > 0 { + isPollExpired = true + poll.update(expired: true) + return + } + + let options = poll.options.sorted(by: { $0.index.intValue < $1.index.intValue }) + let votedOptions = poll.options.filter { option in + (option.votedBy ?? Set()).map { $0.id }.contains(mastodonUser.id) + } + + if !poll.multiple, !votedOptions.isEmpty { + // if did voted for single poll. Do not allow vote again + didVotedLocal = true + return + } + + for option in options { + let voted = choices.contains(option.index.intValue) + option.update(voted: voted, by: mastodonUser) + option.didUpdate(at: option.updatedAt) // trigger update without change anything + } + poll.didUpdate(at: poll.updatedAt) // trigger update without change anything + } + .tryMap { result in + guard !isPollExpired else { + throw APIError.explicit(APIError.ErrorReason.voteExpiredPoll) + } + guard !didVotedLocal else { + throw APIError.implicit(APIError.ErrorReason.badRequest) + } + switch result { + case .success: + guard let targetPollID = _targetPollID else { + throw APIError.implicit(.badRequest) + } + return targetPollID + + case .failure(let error): + assertionFailure(error.localizedDescription) + throw error + } + } + .eraseToAnyPublisher() + } + + /// send vote request to remote + func vote( + domain: String, + pollID: Mastodon.Entity.Poll.ID, + pollObjectID: NSManagedObjectID, + choices: [Int], + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + let requestMastodonUserID = mastodonAuthenticationBox.userID + + let query = Mastodon.API.Polls.VoteQuery(choices: choices) + return Mastodon.API.Polls.vote( + session: session, + domain: domain, + pollID: pollID, + query: query, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let entity = response.value + let managedObjectContext = self.backgroundManagedObjectContext + + return managedObjectContext.performChanges { + let _requestMastodonUser: MastodonUser? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + guard let requestMastodonUser = _requestMastodonUser else { + assertionFailure() + return + } + guard let poll = managedObjectContext.object(with: pollObjectID) as? Poll else { return } + APIService.CoreData.merge(poll: poll, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift index bbf814e6..79fad947 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift @@ -44,13 +44,22 @@ extension APIService.CoreData { if let oldToot = oldToot { // merge old Toot - APIService.CoreData.mergeToot(for: requestMastodonUser, old: oldToot,in: domain, entity: entity, networkDate: networkDate) + APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) return (oldToot, false, false) } else { let (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, 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 poll = entity.poll.flatMap { poll -> Poll in + let options = poll.options.enumerated().map { i, option -> PollOption in + let votedBy: MastodonUser? = (poll.ownVotes ?? []).contains(i) ? requestMastodonUser : nil + return PollOption.insert(into: managedObjectContext, property: PollOption.Property(index: i, title: option.title, votesCount: option.votesCount, networkDate: networkDate), votedBy: votedBy) + } + let votedBy: MastodonUser? = (poll.voted ?? false) ? requestMastodonUser : nil + let object = Poll.insert(into: managedObjectContext, property: Poll.Property(id: poll.id, expiresAt: poll.expiresAt, expired: poll.expired, multiple: poll.multiple, votesCount: poll.votesCount, votersCount: poll.votersCount, networkDate: networkDate), votedBy: votedBy, options: options) + return object + } let metions = entity.mentions?.compactMap { mention -> Mention in Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url)) } @@ -83,6 +92,7 @@ extension APIService.CoreData { author: mastodonUser, reblog: reblog, application: application, + poll: poll, mentions: metions, emojis: emojis, tags: tags, @@ -97,10 +107,21 @@ extension APIService.CoreData { } } - static func mergeToot(for requestMastodonUser: MastodonUser?, old toot: Toot,in domain: String, entity: Mastodon.Entity.Status, networkDate: Date) { + static func merge( + toot: Toot, + entity: Mastodon.Entity.Status, + requestMastodonUser: MastodonUser?, + domain: String, + networkDate: Date + ) { guard networkDate > toot.updatedAt else { return } - // merge + // merge poll + if let poll = toot.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)) } @@ -113,6 +134,7 @@ extension APIService.CoreData { toot.update(reblogsCount:NSNumber(value: entity.reblogsCount)) } + // merge relationship if let mastodonUser = requestMastodonUser { if let favourited = entity.favourited { toot.update(liked: favourited, mastodonUser: mastodonUser) @@ -128,18 +150,44 @@ extension APIService.CoreData { } } - - - // set updateAt toot.didUpdate(at: networkDate) // merge user mergeMastodonUser(for: requestMastodonUser, old: toot.author, in: domain, entity: entity.account, networkDate: networkDate) - // merge indirect reblog & quote + + // merge indirect reblog if let reblog = toot.reblog, let reblogEntity = entity.reblog { - mergeToot(for: requestMastodonUser, old: reblog,in: domain, entity: reblogEntity, networkDate: networkDate) + merge(toot: reblog, entity: reblogEntity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) } } } + +extension APIService.CoreData { + static func merge( + poll: Poll, + entity: Mastodon.Entity.Poll, + requestMastodonUser: MastodonUser?, + domain: String, + networkDate: Date + ) { + poll.update(expiresAt: entity.expiresAt) + poll.update(expired: entity.expired) + poll.update(votesCount: entity.votesCount) + poll.update(votersCount: entity.votersCount) + requestMastodonUser.flatMap { + poll.update(voted: entity.voted ?? false, by: $0) + } + + let oldOptions = poll.options.sorted(by: { $0.index.intValue < $1.index.intValue }) + for (i, (optionEntity, option)) in zip(entity.options, oldOptions).enumerated() { + let voted: Bool = (entity.ownVotes ?? []).contains(i) + option.update(votesCount: optionEntity.votesCount) + requestMastodonUser.flatMap { option.update(voted: voted, by: $0) } + option.didUpdate(at: networkDate) + } + + poll.didUpdate(at: networkDate) + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index 6942fa2f..ce77a51d 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -9,6 +9,7 @@ import Combine import Foundation extension Mastodon.API.Favorites { + static func favoritesStatusesEndpointURL(domain: String) -> URL { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("favourites") } @@ -30,6 +31,22 @@ extension Mastodon.API.Favorites { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) } + /// Favourite / Undo Favourite + /// + /// Add a status to your favourites list / Remove a status from your favourites list + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: Mastodon status id + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Server` nested in the response public static func favorites(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, favoriteKind: FavoriteKind) -> AnyPublisher, Error> { let url: URL = favoriteActionEndpointURL(domain: domain, statusID: statusID, favoriteKind: favoriteKind) var request = Mastodon.API.post(url: url, query: nil, authorization: authorization) @@ -42,7 +59,23 @@ extension Mastodon.API.Favorites { .eraseToAnyPublisher() } - public static func getFavoriteByUserLists(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization) -> AnyPublisher, Error> { + /// Favourited by + /// + /// View who favourited a given status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: Mastodon status id + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Server` nested in the response + public static func favoriteBy(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization) -> AnyPublisher, Error> { let url = favoriteByUserListsEndpointURL(domain: domain, statusID: statusID) let request = Mastodon.API.get(url: url, query: nil, authorization: authorization) return session.dataTaskPublisher(for: request) @@ -53,7 +86,22 @@ extension Mastodon.API.Favorites { .eraseToAnyPublisher() } - public static func getFavoriteStatus(domain: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, query: Mastodon.API.Favorites.ListQuery) -> AnyPublisher, Error> { + /// Favourited statuses + /// + /// Using this endpoint to view the favourited list for user + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/favourites/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Server` nested in the response + public static func favoritedStatus(domain: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, query: Mastodon.API.Favorites.ListQuery) -> AnyPublisher, Error> { let url = favoritesStatusesEndpointURL(domain: domain) let request = Mastodon.API.get(url: url, query: query, authorization: authorization) return session.dataTaskPublisher(for: request) @@ -63,9 +111,11 @@ extension Mastodon.API.Favorites { } .eraseToAnyPublisher() } + } public extension Mastodon.API.Favorites { + enum FavoriteKind { case create case destroy @@ -103,4 +153,5 @@ public extension Mastodon.API.Favorites { return items } } + } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift new file mode 100644 index 00000000..8ed03141 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift @@ -0,0 +1,105 @@ +// +// Mastodon+API+Polls.swift +// +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import Foundation +import Combine + +extension Mastodon.API.Polls { + + static func viewPollEndpointURL(domain: String, pollID: Mastodon.Entity.Poll.ID) -> URL { + let pathComponent = "polls/" + pollID + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + static func votePollEndpointURL(domain: String, pollID: Mastodon.Entity.Poll.ID) -> URL { + let pathComponent = "polls/" + pollID + "/votes" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// View a poll + /// + /// Using this endpoint to view the poll of status + /// + /// - Since: 2.8.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/polls/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - pollID: id for poll + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Poll` nested in the response + public static func poll( + session: URLSession, + domain: String, + pollID: Mastodon.Entity.Poll.ID, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: viewPollEndpointURL(domain: domain, pollID: pollID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Poll.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Vote on a poll + /// + /// Using this endpoint to vote an option of poll + /// + /// - Since: 2.8.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/4 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/polls/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - pollID: id for poll + /// - query: `VoteQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Poll` nested in the response + public static func vote( + session: URLSession, + domain: String, + pollID: Mastodon.Entity.Poll.ID, + query: VoteQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: votePollEndpointURL(domain: domain, pollID: pollID), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Poll.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Polls { + public struct VoteQuery: Codable, PostQuery { + public let choices: [Int] + + public init(choices: [Int]) { + self.choices = choices + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift index d4ec364b..03a718b5 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -53,13 +53,14 @@ extension Mastodon.API.Timeline { /// - Since: 0.0.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/2/19 + /// 2021/3/3 /// # Reference /// [Document](https://https://docs.joinmastodon.org/methods/timelines/) /// - Parameters: /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" /// - query: `PublicTimelineQuery` with query parameters + /// - authorization: User token /// - Returns: `AnyPublisher` contains `Token` nested in the response public static func home( session: URLSession, diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 92897090..5a55ee10 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -5,6 +5,7 @@ // Created by xiaojian sun on 2021/1/25. // +import os.log import Foundation import enum NIOHTTP1.HTTPResponseStatus @@ -93,6 +94,7 @@ extension Mastodon.API { public enum Instance { } public enum OAuth { } public enum Onboarding { } + public enum Polls { } public enum Timeline { } public enum Favorites { } } @@ -155,6 +157,7 @@ extension Mastodon.API { return try Mastodon.API.decoder.decode(type, from: data) } catch let decodeError { #if DEBUG + os_log(.info, "%{public}s[%{public}ld], %{public}s: decode fail. content %s", ((#file as NSString).lastPathComponent), #line, #function, String(data: data, encoding: .utf8) ?? "") debugPrint(decodeError) #endif