diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents
index 3fe5fe16e..3f8fe73f9 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 cfbf48f7e..c9aa22833 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 f3071872f..e580014c1 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 f43dcbf4a..933baab96 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 664933687..552e2a406 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 e58c2e877..0ee0e343b 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 bcbfe5d26..dc88d48a2 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 caec10d32..e659cf891 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 000000000..356f2fc2e
--- /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 000000000..8917a7533
--- /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 b5d8be688..d817c774b 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 b37609a21..c5fcf4869 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 58807fc2f..123655955 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 115a32edd..b3abfd84a 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 d9c64a5ed..60ccd3d87 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 818c33ea8..63c73d3a4 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 000000000..006400f9e
--- /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 000000000..45da63bde
--- /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 4fac88b4c..5f9d43ed5 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 000000000..41ce739ee
--- /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 000000000..22ae6c0b5
--- /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 d8bdb7063..32786b40d 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 035c0011a..7c595918e 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 000000000..cd4e5160d
--- /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 336434ff0..000000000
--- 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 000000000..93f627c09
--- /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 781ccc9f3..a0a7116fc 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 000000000..1b0350086
--- /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 000000000..6e965652d
--- /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 000000000..78cde95fb
--- /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 000000000..2e1ce5f3a
--- /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 7e0375939..91dac809a 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 edc0dce9a..d097fec40 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 70b1446d0..8953c8fb0 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 5fb782c4f..ba375b791 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 4cf8ea52e..54b69e274 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 69f0347e0..0937e1fb4 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 697820072..a0d9204ba 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 d3906fd90..b9d0f94e1 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 d5345de4f..4a34b922a 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 dd5ee97b1..44457839a 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 6d83e79af..aceb83718 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 dd5ffc84e..98d2dbd94 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 f9c92fa0f..d69da8f65 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 be754ed86..c1f3cb3d0 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 000000000..710d8567d
--- /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 000000000..d90be2b09
--- /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 000000000..7aa7ef41d
--- /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 572f23e01..13c3afba4 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 7fd29b6b3..37235c2cb 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 34bd3f0e4..e1d5febe7 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 000000000..0b240466a
--- /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 bbf814e66..79fad947e 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 6942fa2f1..ce77a51d9 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 000000000..8ed031413
--- /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 d4ec364bf..03a718b5b 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 92897090c..5a55ee103 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