From eda3e95ad0cffebc2d62ebce6841f0a6666cc201 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Mar 2021 12:49:04 +0800 Subject: [PATCH 01/15] feat: add poll table view cell --- Mastodon.xcodeproj/project.pbxproj | 10 + Mastodon/Extension/CALayer.swift | 51 +++++ Mastodon/Generated/Assets.swift | 1 + .../Contents.json | 24 ++- .../Contents.json | 6 +- .../Label/secondary.colorset/Contents.json | 6 +- .../lightSecondaryText.colorset/Contents.json | 6 +- .../TableviewCell/PollTableViewCell.swift | 200 ++++++++++++++++++ 8 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 Mastodon/Extension/CALayer.swift create mode 100644 Mastodon/Scene/Share/View/TableviewCell/PollTableViewCell.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9b5894130..0415385bb 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -106,6 +106,7 @@ 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 */; }; 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 */; }; @@ -147,6 +148,8 @@ DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */; }; DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; }; DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; }; + DB92CF7225E7BB98002C1017 /* PollTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB92CF7125E7BB98002C1017 /* PollTableViewCell.swift */; }; + DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98334625C8056600AD9700 /* AuthenticationViewModel.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 */; }; @@ -324,6 +327,7 @@ 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 = ""; }; 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 = ""; }; @@ -366,6 +370,8 @@ DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = ""; }; 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 = ""; }; + DB92CF7125E7BB98002C1017 /* PollTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableViewCell.swift; sourceTree = ""; }; + DB98334625C8056600AD9700 /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.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 = ""; }; @@ -669,6 +675,7 @@ 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */, 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */, + DB92CF7125E7BB98002C1017 /* PollTableViewCell.swift */, ); path = TableviewCell; sourceTree = ""; @@ -997,6 +1004,7 @@ isa = PBXGroup; children = ( DB084B5125CBC56300F898ED /* CoreDataStack */, + DB44384E25E8C1FA008912A2 /* CALayer.swift */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, @@ -1448,6 +1456,7 @@ 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, + DB92CF7225E7BB98002C1017 /* PollTableViewCell.swift in Sources */, DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */, 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, @@ -1508,6 +1517,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+Entidy+ErrorDetailReason.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, 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/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 08507ed9d..a4f4e0803 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -71,6 +71,7 @@ internal enum Asset { internal enum Welcome { internal static let mastodonLogo = ImageAsset(name: "Welcome/mastodon.logo") internal static let mastodonLogoLarge = ImageAsset(name: "Welcome/mastodon.logo.large") + internal static let welcomeLogo = ImageAsset(name: "Welcome/welcome.logo") } } // swiftlint:enable identifier_name line_length nesting type_body_length type_name 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/Scene/Share/View/TableviewCell/PollTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollTableViewCell.swift new file mode 100644 index 000000000..d41fd7428 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/PollTableViewCell.swift @@ -0,0 +1,200 @@ +// +// PollTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-2-25. +// + +import UIKit + +final class PollTableViewCell: UITableViewCell { + + static let checkmarkImageSize = CGSize(width: 26, height: 26) + + let roundedBackgroundView = UIView() + + 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 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() + } + +} + +extension PollTableViewCell { + + private func _init() { + 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), + ]) + + 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: PollTableViewCell.checkmarkImageSize.width).priority(.defaultHigh), + checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: PollTableViewCell.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), + ]) + + optionPercentageLabel.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(optionPercentageLabel) + NSLayoutConstraint.activate([ + optionPercentageLabel.leadingAnchor.constraint(equalTo: optionLabel.trailingAnchor, constant: 8), + roundedBackgroundView.trailingAnchor.constraint(equalTo: optionPercentageLabel.trailingAnchor, constant: 18), + optionPercentageLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), + ]) + + configureCheckmark(state: .none) + } + + override func layoutSubviews() { + super.layoutSubviews() + + roundedBackgroundView.layer.masksToBounds = true + roundedBackgroundView.layer.cornerRadius = roundedBackgroundView.bounds.height * 0.5 + roundedBackgroundView.layer.cornerCurve = .circular + + checkmarkBackgroundView.layer.masksToBounds = true + checkmarkBackgroundView.layer.cornerRadius = checkmarkBackgroundView.bounds.height * 0.5 + checkmarkBackgroundView.layer.cornerCurve = .circular + } + +} + +extension PollTableViewCell { + + enum CheckmarkState { + case none + case off + case on + } + + func configureCheckmark(state: CheckmarkState) { + switch state { + case .none: + checkmarkBackgroundView.backgroundColor = .clear + checkmarkImageView.isHidden = true + optionPercentageLabel.isHidden = true + optionLabel.textColor = Asset.Colors.Label.primary.color + optionLabel.layer.removeShadow() + case .off: + checkmarkBackgroundView.backgroundColor = .systemBackground + checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor + checkmarkBackgroundView.layer.borderWidth = 1 + checkmarkImageView.isHidden = true + optionPercentageLabel.isHidden = true + optionLabel.textColor = Asset.Colors.Label.primary.color + optionLabel.layer.removeShadow() + case .on: + checkmarkBackgroundView.backgroundColor = .systemBackground + checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor + checkmarkBackgroundView.layer.borderWidth = 0 + checkmarkImageView.isHidden = false + optionPercentageLabel.isHidden = false + optionLabel.textColor = .white + optionLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) + } + } + +} + + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct PollTableViewCell_Previews: PreviewProvider { + + static var controls: some View { + Group { + UIViewPreview() { + PollTableViewCell() + } + .previewLayout(.fixed(width: 375, height: 44 + 10)) + UIViewPreview() { + let cell = PollTableViewCell() + cell.configureCheckmark(state: .off) + return cell + } + .previewLayout(.fixed(width: 375, height: 44 + 10)) + UIViewPreview() { + let cell = PollTableViewCell() + cell.configureCheckmark(state: .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 + From 80954b04925f67a936a43fb988e83f542df7d0c1 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Mar 2021 15:51:16 +0800 Subject: [PATCH 02/15] feat: add Poll and PollOption entity to CoreDataStack --- .../CoreData.xcdatamodel/contents | 31 +++++- CoreDataStack/Entity/MastodonUser.swift | 1 + CoreDataStack/Entity/Poll.swift | 96 +++++++++++++++++++ CoreDataStack/Entity/PollOption.swift | 76 +++++++++++++++ CoreDataStack/Entity/Tag.swift | 11 ++- CoreDataStack/Entity/Toot.swift | 3 + Mastodon.xcodeproj/project.pbxproj | 10 +- .../xcschemes/xcschememanagement.plist | 4 +- Mastodon/Generated/Assets.swift | 1 - ...meTimelineViewController+DebugAction.swift | 55 +++++++++++ .../CoreData/APIService+CoreData+Toot.swift | 12 ++- 11 files changed, 284 insertions(+), 16 deletions(-) create mode 100644 CoreDataStack/Entity/Poll.swift create mode 100644 CoreDataStack/Entity/PollOption.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 3fe5fe16e..1ef9f929d 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -83,6 +83,7 @@ + @@ -93,6 +94,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -131,6 +153,7 @@ + @@ -138,14 +161,16 @@ + - + - - + + + \ No newline at end of file diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index bcbfe5d26..8ecf66282 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -37,6 +37,7 @@ 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? } diff --git a/CoreDataStack/Entity/Poll.swift b/CoreDataStack/Entity/Poll.swift new file mode 100644 index 000000000..1e8b2528f --- /dev/null +++ b/CoreDataStack/Entity/Poll.swift @@ -0,0 +1,96 @@ +// +// 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 +} + +extension Poll { + + public override func awakeFromInsert() { + super.awakeFromInsert() + createdAt = Date() + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property, + 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 + poll.mutableSetValue(forKey: #keyPath(Poll.options)).addObjects(from: options) + + return poll + } + +} + +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..f0d3219d8 --- /dev/null +++ b/CoreDataStack/Entity/PollOption.swift @@ -0,0 +1,76 @@ +// +// 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() + createdAt = Date() + } + + @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 + } + +} + +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..3f5d2bcac 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() } + @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/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 0415385bb..cd08985c5 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -107,6 +107,8 @@ 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 */; }; 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 */; }; @@ -149,7 +151,6 @@ DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; }; DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; }; DB92CF7225E7BB98002C1017 /* PollTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB92CF7125E7BB98002C1017 /* PollTableViewCell.swift */; }; - DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98334625C8056600AD9700 /* AuthenticationViewModel.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 */; }; @@ -328,6 +329,8 @@ 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 = ""; }; 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 = ""; }; @@ -371,7 +374,6 @@ 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 = ""; }; DB92CF7125E7BB98002C1017 /* PollTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableViewCell.swift; sourceTree = ""; }; - DB98334625C8056600AD9700 /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.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 = ""; }; @@ -945,6 +947,8 @@ DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */, 2DA7D05625CA693F00804E11 /* Application.swift */, DB9D6C2D25E504AC0051B173 /* Attachment.swift */, + DB4481AC25EE155900BEFB67 /* Poll.swift */, + DB4481B225EE16D000BEFB67 /* PollOption.swift */, ); path = Entity; sourceTree = ""; @@ -1590,8 +1594,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 b1a7a744c..bc78dfa4b 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -17,12 +17,12 @@ Mastodon - Release.xcscheme_^#shared#^_ orderHint - 1 + 2 Mastodon.xcscheme_^#shared#^_ orderHint - 0 + 1 SuppressBuildableAutocreation diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index a4f4e0803..08507ed9d 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -71,7 +71,6 @@ internal enum Asset { internal enum Welcome { internal static let mastodonLogo = ImageAsset(name: "Welcome/mastodon.logo") internal static let mastodonLogoLarge = ImageAsset(name: "Welcome/mastodon.logo.large") - internal static let welcomeLogo = ImageAsset(name: "Welcome/welcome.logo") } } // swiftlint:enable identifier_name line_length nesting type_body_length type_name diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 69f0347e0..9c3af1f73 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,7 @@ extension HomeTimelineViewController { identifier: nil, options: .displayInline, children: [ + 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 +32,62 @@ extension HomeTimelineViewController { ) return menu } + + 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) Tweets", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.dropRecentTweetsAction(action, count: count) + }) + } + ) + } } extension HomeTimelineViewController { + @objc private func dropRecentTweetsAction(_ 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) + } + } + 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/Service/APIService/CoreData/APIService+CoreData+Toot.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift index bbf814e66..eeb2afa2a 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift @@ -51,6 +51,14 @@ extension APIService.CoreData { 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 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), 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 +91,7 @@ extension APIService.CoreData { author: mastodonUser, reblog: reblog, application: application, + poll: poll, mentions: metions, emojis: emojis, tags: tags, @@ -128,9 +137,6 @@ extension APIService.CoreData { } } - - - // set updateAt toot.didUpdate(at: networkDate) From 8b63c2fda133eedc8da209c410974b1f7cb2a4d0 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Mar 2021 16:27:11 +0800 Subject: [PATCH 03/15] feat: add PollSection and PollItem for diffable data source --- Mastodon.xcodeproj/project.pbxproj | 14 +++- Mastodon/Diffiable/Item/PollItem.swift | 49 ++++++++++++ Mastodon/Diffiable/Section/PollSection.swift | 25 ++++++ .../Diffiable/Section/StatusSection.swift | 3 + Mastodon/Extension/UITableView.swift | 55 +++++++++++++ ...meTimelineViewController+DebugAction.swift | 77 ++++++++++++++++++- .../HomeTimeline/HomeTimelineViewModel.swift | 6 +- .../Scene/Share/View/Content/StatusView.swift | 14 +++- .../TableviewCell/StatusTableViewCell.swift | 1 + 9 files changed, 235 insertions(+), 9 deletions(-) create mode 100644 Mastodon/Diffiable/Item/PollItem.swift create mode 100644 Mastodon/Diffiable/Section/PollSection.swift create mode 100644 Mastodon/Extension/UITableView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index cd08985c5..e1283e374 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -109,6 +109,9 @@ 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 */; }; @@ -331,6 +334,9 @@ 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 = ""; }; @@ -634,8 +640,8 @@ 2D76319C25C151DE00929FB9 /* Diffiable */ = { isa = PBXGroup; children = ( - 2D7631B125C159E700929FB9 /* Item */, 2D76319D25C151F600929FB9 /* Section */, + 2D7631B125C159E700929FB9 /* Item */, ); path = Diffiable; sourceTree = ""; @@ -644,6 +650,7 @@ isa = PBXGroup; children = ( 2D76319E25C1521200929FB9 /* StatusSection.swift */, + DB4481C525EE2ADA00BEFB67 /* PollSection.swift */, ); path = Section; sourceTree = ""; @@ -686,6 +693,7 @@ isa = PBXGroup; children = ( 2D7631B225C159F700929FB9 /* Item.swift */, + DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */, ); path = Item; sourceTree = ""; @@ -1021,6 +1029,7 @@ DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */, DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */, + DB4481B825EE289600BEFB67 /* UITableView.swift */, 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, 0FAA101B25E10E760017CCDE /* UIFont.swift */, 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */, @@ -1476,7 +1485,9 @@ 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 */, @@ -1501,6 +1512,7 @@ 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 */, diff --git a/Mastodon/Diffiable/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift new file mode 100644 index 000000000..7a13df413 --- /dev/null +++ b/Mastodon/Diffiable/Item/PollItem.swift @@ -0,0 +1,49 @@ +// +// PollItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-2. +// + +import Foundation +import CoreData + +enum PollItem { + case pollOpion(objectID: NSManagedObjectID, attribute: Attribute) +} + + +extension PollItem { + class Attribute: Hashable { + var voted: Bool = false + + static func == (lhs: PollItem.Attribute, rhs: PollItem.Attribute) -> Bool { + return lhs.voted == rhs.voted + } + + func hash(into hasher: inout Hasher) { + hasher.combine(voted) + } + } +} + +extension PollItem: Equatable { + static func == (lhs: PollItem, rhs: PollItem) -> Bool { + switch (lhs, rhs) { + case (.pollOpion(let objectIDLeft, _), .pollOpion(let objectIDRight, _)): + return objectIDLeft == objectIDRight + default: + return false + } + } +} + + +extension PollItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .pollOpion(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..9b175c3f9 --- /dev/null +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -0,0 +1,25 @@ +// +// PollSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-2. +// + +import UIKit +import CoreData +import CoreDataStack + +enum PollSection { + case main +} + +extension PollSection { + static func tableViewDiffableDataSource( + for tableView: UITableView, + managedObjectContext: NSManagedObjectContext + ) -> UITableViewDiffableDataSource { + return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in + return nil + } + } +} diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 4fac88b4c..89bf1c6e1 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -153,6 +153,9 @@ extension StatusSection { 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 + + // set poll + // toolbar let replyCountTitle: String = { 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/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 9c3af1f73..bb6d6dae4 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -19,6 +19,7 @@ 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 } @@ -33,6 +34,41 @@ 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…", @@ -40,9 +76,9 @@ extension HomeTimelineViewController { identifier: nil, options: [], children: [50, 100, 150, 200, 250, 300].map { count in - UIAction(title: "Drop Recent \(count) Tweets", image: nil, attributes: [], handler: { [weak self] action in + UIAction(title: "Drop Recent \(count) Toots", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } - self.dropRecentTweetsAction(action, count: count) + self.dropRecentTootsAction(action, count: count) }) } ) @@ -51,7 +87,42 @@ extension HomeTimelineViewController { extension HomeTimelineViewController { - @objc private func dropRecentTweetsAction(_ sender: UIAction, count: Int) { + @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() 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/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index be754ed86..abaa38f33 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -23,6 +23,7 @@ final class StatusView: UIView { weak var delegate: StatusViewDelegate? var isStatusTextSensitive = false + var statusPollTableViewDataSource: UITableViewDiffableDataSource? let headerContainerStackView = UIStackView() @@ -101,6 +102,13 @@ final class StatusView: UIView { }() let statusMosaicImageView = MosaicImageViewContainer() + let statusPollTableView: UITableView = { + let tableView = UITableView() + tableView.register(PollTableViewCell.self, forCellReuseIdentifier: String(describing: PollTableViewCell.self)) + tableView.isScrollEnabled = false + return tableView + }() + // do not use visual effect view due to we blur text only without background let contentWarningBlurContentImageView: UIImageView = { let imageView = UIImageView() @@ -222,7 +230,7 @@ extension StatusView { subtitleContainerStackView.axis = .horizontal subtitleContainerStackView.addArrangedSubview(usernameLabel) - // status container: [status | image / video | audio] + // status container: [status | image / video | audio | poll] containerStackView.addArrangedSubview(statusContainerStackView) statusContainerStackView.axis = .vertical statusContainerStackView.spacing = 10 @@ -258,7 +266,7 @@ extension StatusView { statusContentWarningContainerStackView.addArrangedSubview(contentWarningTitle) statusContentWarningContainerStackView.addArrangedSubview(contentWarningActionButton) statusContainerStackView.addArrangedSubview(statusMosaicImageView) - + statusContainerStackView.addArrangedSubview(statusPollTableView) // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) @@ -266,6 +274,8 @@ extension StatusView { headerContainerStackView.isHidden = true statusMosaicImageView.isHidden = true + statusPollTableView.isHidden = true + contentWarningBlurContentImageView.isHidden = true statusContentWarningContainerStackView.isHidden = true statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 572f23e01..eb1a015b4 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -33,6 +33,7 @@ final class StatusTableViewCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() statusView.isStatusTextSensitive = false + statusView.statusPollTableView.dataSource = nil statusView.cleanUpContentWarning() disposeBag.removeAll() observations.removeAll() From aea2ddc078f0ec8f7929a7997161e947e13a7b88 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Mar 2021 19:10:45 +0800 Subject: [PATCH 04/15] feat: make toot poll display --- .../CoreData.xcdatamodel/contents | 6 ++-- Mastodon.xcodeproj/project.pbxproj | 8 ++--- Mastodon/Diffiable/Item/PollItem.swift | 12 ++++--- Mastodon/Diffiable/Section/PollSection.swift | 22 +++++++++++-- .../Diffiable/Section/StatusSection.swift | 26 ++++++++++++++- ...meTimelineViewController+DebugAction.swift | 4 +++ .../Scene/Share/View/Content/StatusView.swift | 31 +++++++++++++++--- ...ll.swift => PollOptionTableViewCell.swift} | 32 +++++++++++++------ .../TableviewCell/StatusTableViewCell.swift | 1 - 9 files changed, 112 insertions(+), 30 deletions(-) rename Mastodon/Scene/Share/View/TableviewCell/{PollTableViewCell.swift => PollOptionTableViewCell.swift} (86%) diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 1ef9f929d..96ec6971d 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -150,7 +150,7 @@ - + @@ -168,9 +168,9 @@ - - + + \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index e1283e374..7509264bb 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -153,7 +153,7 @@ DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */; }; DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; }; DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; }; - DB92CF7225E7BB98002C1017 /* PollTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB92CF7125E7BB98002C1017 /* PollTableViewCell.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 */; }; @@ -379,7 +379,7 @@ DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = ""; }; 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 = ""; }; - DB92CF7125E7BB98002C1017 /* PollTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableViewCell.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 = ""; }; @@ -684,7 +684,7 @@ 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */, 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */, - DB92CF7125E7BB98002C1017 /* PollTableViewCell.swift */, + DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */, ); path = TableviewCell; sourceTree = ""; @@ -1469,7 +1469,7 @@ 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, - DB92CF7225E7BB98002C1017 /* PollTableViewCell.swift in Sources */, + DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */, DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */, 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift index 7a13df413..1ae8f34e3 100644 --- a/Mastodon/Diffiable/Item/PollItem.swift +++ b/Mastodon/Diffiable/Item/PollItem.swift @@ -9,7 +9,7 @@ import Foundation import CoreData enum PollItem { - case pollOpion(objectID: NSManagedObjectID, attribute: Attribute) + case opion(objectID: NSManagedObjectID, attribute: Attribute) } @@ -17,6 +17,10 @@ extension PollItem { class Attribute: Hashable { var voted: Bool = false + init(voted: Bool = false) { + self.voted = voted + } + static func == (lhs: PollItem.Attribute, rhs: PollItem.Attribute) -> Bool { return lhs.voted == rhs.voted } @@ -30,10 +34,8 @@ extension PollItem { extension PollItem: Equatable { static func == (lhs: PollItem, rhs: PollItem) -> Bool { switch (lhs, rhs) { - case (.pollOpion(let objectIDLeft, _), .pollOpion(let objectIDRight, _)): + case (.opion(let objectIDLeft, _), .opion(let objectIDRight, _)): return objectIDLeft == objectIDRight - default: - return false } } } @@ -42,7 +44,7 @@ extension PollItem: Equatable { extension PollItem: Hashable { func hash(into hasher: inout Hasher) { switch self { - case .pollOpion(let objectID, _): + case .opion(let objectID, _): hasher.combine(objectID) } } diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index 9b175c3f9..b48231dbf 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -9,7 +9,7 @@ import UIKit import CoreData import CoreDataStack -enum PollSection { +enum PollSection: Equatable, Hashable { case main } @@ -19,7 +19,25 @@ extension PollSection { managedObjectContext: NSManagedObjectContext ) -> UITableViewDiffableDataSource { return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in - return nil + 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, itemAttribute: attribute) + } + return cell + } } } } + +extension PollSection { + static func configure( + cell: PollOptionTableViewCell, + pollOption: PollOption, + itemAttribute: PollItem.Attribute + ) { + cell.optionLabel.text = pollOption.title + } +} diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 89bf1c6e1..b8838869c 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -155,7 +155,31 @@ extension StatusSection { cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 // set poll - + if let poll = (toot.reblog ?? toot).poll { + cell.statusView.statusPollTableView.isHidden = false + + let managedObjectContext = toot.managedObjectContext! + cell.statusView.statusPollTableViewDataSource = PollSection.tableViewDiffableDataSource( + for: cell.statusView.statusPollTableView, + 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 isVoted = (option.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + let attribute = PollItem.Attribute(voted: isVoted) + let option = PollItem.opion(objectID: option.objectID, attribute: attribute) + return option + } + snapshot.appendItems(pollItems, toSection: .main) + cell.statusView.statusPollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + // cell.statusView.statusPollTableView.layoutIfNeeded() + } else { + cell.statusView.statusPollTableView.isHidden = true + } // toolbar let replyCountTitle: String = { diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index bb6d6dae4..0937e1fb4 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -152,6 +152,10 @@ extension HomeTimelineViewController { self.context.apiService.backgroundManagedObjectContext.delete(toot) } } + .sink { _ in + // do nothing + } + .store(in: &self.disposeBag) case .failure(let error): assertionFailure(error.localizedDescription) } diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index abaa38f33..77cc851c1 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -17,6 +17,8 @@ protocol StatusViewDelegate: class { 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 @@ -24,6 +26,7 @@ final class StatusView: UIView { weak var delegate: StatusViewDelegate? var isStatusTextSensitive = false var statusPollTableViewDataSource: UITableViewDiffableDataSource? + var statusPollTableViewHeightLaoutConstraint: NSLayoutConstraint! let headerContainerStackView = UIStackView() @@ -103,9 +106,11 @@ final class StatusView: UIView { let statusMosaicImageView = MosaicImageViewContainer() let statusPollTableView: UITableView = { - let tableView = UITableView() - tableView.register(PollTableViewCell.self, forCellReuseIdentifier: String(describing: PollTableViewCell.self)) + let tableView = UITableView(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 }() @@ -144,6 +149,10 @@ final class StatusView: UIView { drawContentWarningImageView() } } + + deinit { + statusPollTableViewHeightObservation = nil + } } @@ -265,8 +274,23 @@ extension StatusView { ]) statusContentWarningContainerStackView.addArrangedSubview(contentWarningTitle) statusContentWarningContainerStackView.addArrangedSubview(contentWarningActionButton) + statusContainerStackView.addArrangedSubview(statusMosaicImageView) + statusPollTableView.translatesAutoresizingMaskIntoConstraints = false statusContainerStackView.addArrangedSubview(statusPollTableView) + statusPollTableViewHeightLaoutConstraint = statusPollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1) + NSLayoutConstraint.activate([ + statusPollTableViewHeightLaoutConstraint, + ]) + + statusPollTableViewHeightObservation = statusPollTableView.observe(\.contentSize, options: .new, changeHandler: { [weak self] tableView, _ in + guard let self = self else { return } + guard self.statusPollTableView.contentSize.height != .zero else { + self.statusPollTableViewHeightLaoutConstraint.constant = 44 + return + } + self.statusPollTableViewHeightLaoutConstraint.constant = self.statusPollTableView.contentSize.height + }) // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) @@ -322,14 +346,13 @@ extension StatusView { } } +// 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 diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift similarity index 86% rename from Mastodon/Scene/Share/View/TableviewCell/PollTableViewCell.swift rename to Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index d41fd7428..5372380bd 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -1,5 +1,5 @@ // -// PollTableViewCell.swift +// PollOptionTableViewCell.swift // Mastodon // // Created by MainasuK Cirno on 2021-2-25. @@ -7,8 +7,11 @@ import UIKit -final class PollTableViewCell: UITableViewCell { +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) let roundedBackgroundView = UIView() @@ -57,9 +60,11 @@ final class PollTableViewCell: UITableViewCell { } -extension PollTableViewCell { +extension PollOptionTableViewCell { private func _init() { + selectionStyle = .none + backgroundColor = .clear roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false @@ -69,6 +74,7 @@ extension PollTableViewCell { 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), ]) checkmarkBackgroundView.translatesAutoresizingMaskIntoConstraints = false @@ -77,8 +83,8 @@ extension PollTableViewCell { 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: PollTableViewCell.checkmarkImageSize.width).priority(.defaultHigh), - checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: PollTableViewCell.checkmarkImageSize.height).priority(.defaultHigh), + checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: PollOptionTableViewCell.checkmarkImageSize.width).priority(.defaultHigh), + checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionTableViewCell.checkmarkImageSize.height).priority(.defaultHigh), ]) checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false @@ -104,6 +110,8 @@ extension PollTableViewCell { 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) configureCheckmark(state: .none) } @@ -111,8 +119,12 @@ extension PollTableViewCell { override func layoutSubviews() { super.layoutSubviews() + updateCornerRadius() + } + + private func updateCornerRadius() { roundedBackgroundView.layer.masksToBounds = true - roundedBackgroundView.layer.cornerRadius = roundedBackgroundView.bounds.height * 0.5 + roundedBackgroundView.layer.cornerRadius = PollOptionTableViewCell.optionHeight * 0.5 roundedBackgroundView.layer.cornerCurve = .circular checkmarkBackgroundView.layer.masksToBounds = true @@ -122,7 +134,7 @@ extension PollTableViewCell { } -extension PollTableViewCell { +extension PollOptionTableViewCell { enum CheckmarkState { case none @@ -168,17 +180,17 @@ struct PollTableViewCell_Previews: PreviewProvider { static var controls: some View { Group { UIViewPreview() { - PollTableViewCell() + PollOptionTableViewCell() } .previewLayout(.fixed(width: 375, height: 44 + 10)) UIViewPreview() { - let cell = PollTableViewCell() + let cell = PollOptionTableViewCell() cell.configureCheckmark(state: .off) return cell } .previewLayout(.fixed(width: 375, height: 44 + 10)) UIViewPreview() { - let cell = PollTableViewCell() + let cell = PollOptionTableViewCell() cell.configureCheckmark(state: .on) return cell } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index eb1a015b4..900094c57 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -10,7 +10,6 @@ import UIKit import AVKit import Combine - protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) From 376cb3d58aee1190966c485a2f76d4620682f8dc Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Mar 2021 19:33:33 +0800 Subject: [PATCH 05/15] feat: display toot poll status --- Localization/app.json | 9 ++- .../Diffiable/Section/StatusSection.swift | 19 +++--- Mastodon/Generated/Strings.swift | 18 +++++- ...er+TimelinePostTableViewCellDelegate.swift | 4 +- .../Resources/en.lproj/Localizable.strings | 5 +- .../Scene/Share/View/Content/StatusView.swift | 58 ++++++++++++++----- .../TableviewCell/StatusTableViewCell.swift | 4 +- 7 files changed, 87 insertions(+), 30 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 3a45922a5..7b118b34d 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -58,7 +58,14 @@ "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_count": { + "single": "%d vote", + "multiple": "%d votes", + }, + "time_left": "%s left" + } }, "timeline": { "load_more": "Load More" diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index b8838869c..2fd87f4d1 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -132,14 +132,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,18 +149,19 @@ extension StatusSection { ) } } - cell.statusView.statusMosaicImageView.isHidden = mosiacImageViewModel.metas.isEmpty + cell.statusView.statusMosaicImageViewContainer.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.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil + cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 // set poll if let poll = (toot.reblog ?? toot).poll { - cell.statusView.statusPollTableView.isHidden = false + cell.statusView.pollTableView.isHidden = false + cell.statusView.pollStatusStackView.isHidden = false let managedObjectContext = toot.managedObjectContext! cell.statusView.statusPollTableViewDataSource = PollSection.tableViewDiffableDataSource( - for: cell.statusView.statusPollTableView, + for: cell.statusView.pollTableView, managedObjectContext: managedObjectContext ) @@ -176,9 +177,9 @@ extension StatusSection { } snapshot.appendItems(pollItems, toSection: .main) cell.statusView.statusPollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) - // cell.statusView.statusPollTableView.layoutIfNeeded() } else { - cell.statusView.statusPollTableView.isHidden = true + cell.statusView.pollTableView.isHidden = true + cell.statusView.pollStatusStackView.isHidden = true } // toolbar diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 8e93c804e..c049f4fca 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -68,6 +68,22 @@ internal enum L10n { internal static func userBoosted(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) } + internal enum Poll { + /// %@ left + internal static func timeLeft(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Poll.TimeLeft", String(describing: p1)) + } + 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 Timeline { /// Load More @@ -124,7 +140,7 @@ internal enum L10n { internal static let passwordTooShrot = L10n.tr("Localizable", "Common.Errors.Itemdetail.PasswordTooShrot") /// Username must only contain alphanumeric characters and underscores internal static let usernameInvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.UsernameInvalid") - /// username is too long ( can't be longer than 30 characters) + /// username is too long (can't be longer than 30 characters) internal static let usernameTooLong = L10n.tr("Localizable", "Common.Errors.Itemdetail.UsernameTooLong") } } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift index 336434ff0..4679969e2 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift @@ -69,8 +69,8 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { 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 + cell.statusView.statusMosaicImageViewContainer.blurVisualEffectView.effect = nil + cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = 0.0 } completion: { _ in diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 191ec0daf..9b1dfdf7a 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -17,6 +17,9 @@ "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.TimeLeft" = "%@ left"; +"Common.Controls.Status.Poll.VoteCount.Multiple" = "%d votes"; +"Common.Controls.Status.Poll.VoteCount.Single" = "%d vote"; "Common.Controls.Status.ShowPost" = "Show Post"; "Common.Controls.Status.StatusContentWarning" = "content warning"; "Common.Controls.Status.UserBoosted" = "%@ boosted"; @@ -42,7 +45,7 @@ "Common.Errors.Itemdetail.EmailInvalid" = "This is not a valid e-mail address"; "Common.Errors.Itemdetail.PasswordTooShrot" = "password is too short (must be at least 8 characters)"; "Common.Errors.Itemdetail.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores"; -"Common.Errors.Itemdetail.UsernameTooLong" = "username is too long ( can't be longer than 30 characters)"; +"Common.Errors.Itemdetail.UsernameTooLong" = "username is too long (can't be longer than 30 characters)"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t."; diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 77cc851c1..702d9c7e4 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -103,9 +103,9 @@ final class StatusView: UIView { button.setTitle(L10n.Common.Controls.Status.showPost, for: .normal) return button }() - let statusMosaicImageView = MosaicImageViewContainer() + let statusMosaicImageViewContainer = MosaicImageViewContainer() - let statusPollTableView: UITableView = { + let pollTableView: UITableView = { let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) tableView.register(PollOptionTableViewCell.self, forCellReuseIdentifier: String(describing: PollOptionTableViewCell.self)) tableView.isScrollEnabled = false @@ -114,6 +114,29 @@ final class StatusView: UIView { 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 + }() + // do not use visual effect view due to we blur text only without background let contentWarningBlurContentImageView: UIImageView = { let imageView = UIImageView() @@ -239,7 +262,7 @@ extension StatusView { subtitleContainerStackView.axis = .horizontal subtitleContainerStackView.addArrangedSubview(usernameLabel) - // status container: [status | image / video | audio | poll] + // status container: [status | image / video | audio | poll | poll status] containerStackView.addArrangedSubview(statusContainerStackView) statusContainerStackView.axis = .vertical statusContainerStackView.spacing = 10 @@ -275,30 +298,37 @@ extension StatusView { statusContentWarningContainerStackView.addArrangedSubview(contentWarningTitle) statusContentWarningContainerStackView.addArrangedSubview(contentWarningActionButton) - statusContainerStackView.addArrangedSubview(statusMosaicImageView) - statusPollTableView.translatesAutoresizingMaskIntoConstraints = false - statusContainerStackView.addArrangedSubview(statusPollTableView) - statusPollTableViewHeightLaoutConstraint = statusPollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1) + statusContainerStackView.addArrangedSubview(statusMosaicImageViewContainer) + pollTableView.translatesAutoresizingMaskIntoConstraints = false + statusContainerStackView.addArrangedSubview(pollTableView) + statusPollTableViewHeightLaoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1) NSLayoutConstraint.activate([ statusPollTableViewHeightLaoutConstraint, ]) - statusPollTableViewHeightObservation = statusPollTableView.observe(\.contentSize, options: .new, changeHandler: { [weak self] tableView, _ in + statusPollTableViewHeightObservation = pollTableView.observe(\.contentSize, options: .new, changeHandler: { [weak self] tableView, _ in guard let self = self else { return } - guard self.statusPollTableView.contentSize.height != .zero else { + guard self.pollTableView.contentSize.height != .zero else { self.statusPollTableViewHeightLaoutConstraint.constant = 44 return } - self.statusPollTableViewHeightLaoutConstraint.constant = self.statusPollTableView.contentSize.height + self.statusPollTableViewHeightLaoutConstraint.constant = self.pollTableView.contentSize.height }) + statusContainerStackView.addArrangedSubview(pollStatusStackView) + pollStatusStackView.axis = .horizontal + pollStatusStackView.addArrangedSubview(pollVoteCountLabel) + pollStatusStackView.addArrangedSubview(pollStatusDotLabel) + pollStatusStackView.addArrangedSubview(pollCountdownLabel) + // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) headerContainerStackView.isHidden = true - statusMosaicImageView.isHidden = true - statusPollTableView.isHidden = true + statusMosaicImageViewContainer.isHidden = true + pollTableView.isHidden = true + pollStatusStackView.isHidden = true contentWarningBlurContentImageView.isHidden = true statusContentWarningContainerStackView.isHidden = true @@ -390,11 +420,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/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 900094c57..326687b10 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -32,7 +32,7 @@ final class StatusTableViewCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() statusView.isStatusTextSensitive = false - statusView.statusPollTableView.dataSource = nil + statusView.pollTableView.dataSource = nil statusView.cleanUpContentWarning() disposeBag.removeAll() observations.removeAll() @@ -85,7 +85,7 @@ extension StatusTableViewCell { bottomPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color statusView.delegate = self - statusView.statusMosaicImageView.delegate = self + statusView.statusMosaicImageViewContainer.delegate = self statusView.actionToolbarContainer.delegate = self } From 1e691a2a762f1b2921dc7a92f4576eda9ad0b121 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 3 Mar 2021 12:46:38 +0800 Subject: [PATCH 06/15] fix: AutoLayout warning for poll UI --- Mastodon/Diffiable/Section/PollSection.swift | 2 ++ Mastodon/Scene/Share/View/Content/StatusView.swift | 4 ++++ .../Share/View/TableviewCell/PollOptionTableViewCell.swift | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index b48231dbf..08f1f8710 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -39,5 +39,7 @@ extension PollSection { itemAttribute: PollItem.Attribute ) { cell.optionLabel.text = pollOption.title + cell.configureCheckmark(state: itemAttribute.voted ? .on : .off) + } } diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 702d9c7e4..55f785ca3 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -276,6 +276,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([ @@ -320,6 +321,9 @@ extension StatusView { pollStatusStackView.addArrangedSubview(pollVoteCountLabel) pollStatusStackView.addArrangedSubview(pollStatusDotLabel) pollStatusStackView.addArrangedSubview(pollCountdownLabel) + pollVoteCountLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal) + pollStatusDotLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) + pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index 5372380bd..eb427bc31 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -128,7 +128,7 @@ extension PollOptionTableViewCell { roundedBackgroundView.layer.cornerCurve = .circular checkmarkBackgroundView.layer.masksToBounds = true - checkmarkBackgroundView.layer.cornerRadius = checkmarkBackgroundView.bounds.height * 0.5 + checkmarkBackgroundView.layer.cornerRadius = PollOptionTableViewCell.checkmarkImageSize.width * 0.5 checkmarkBackgroundView.layer.cornerCurve = .circular } From 30c035e09a25d68539f0cb63510212e4613a2803 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 3 Mar 2021 16:12:48 +0800 Subject: [PATCH 07/15] feat: implement auto refresh logic for Poll --- CoreDataStack/Entity/Poll.swift | 28 +++++++ CoreDataStack/Entity/PollOption.swift | 16 ++++ Localization/app.json | 5 ++ Mastodon.xcodeproj/project.pbxproj | 36 +++++++-- Mastodon/Diffiable/Item/PollItem.swift | 12 +-- Mastodon/Diffiable/Section/PollSection.swift | 3 +- .../Diffiable/Section/StatusSection.swift | 35 ++++++-- Mastodon/Generated/Strings.swift | 12 +++ ...Provider+StatusTableViewCellDelegate.swift | 71 ++++++++++++++++ ...er+TimelinePostTableViewCellDelegate.swift | 81 ------------------- .../StatusProvider+UITableViewDelegate.swift | 71 ++++++++++++++++ .../StatusProvider/StatusProvider.swift | 6 +- ...ableViewCellHeightCacheableContainer.swift | 12 +++ .../Resources/en.lproj/Localizable.strings | 3 + ...imelineViewController+StatusProvider.swift | 30 +++---- .../HomeTimelineViewController.swift | 27 ++++--- .../HomeTimelineViewModel+Diffable.swift | 4 +- ...imelineViewController+StatusProvider.swift | 31 +++---- .../PublicTimelineViewController.swift | 2 +- .../PublicTimelineViewModel+Diffable.swift | 4 +- .../Scene/Share/View/Content/StatusView.swift | 27 +++++-- .../Share/View/TableView/PollTableView.swift | 10 +++ .../PollOptionTableViewCell.swift | 30 +++++-- .../TableviewCell/StatusTableViewCell.swift | 24 +++++- .../APIService/APIService+Favorite.swift | 4 +- .../Service/APIService/APIService+Poll.swift | 71 ++++++++++++++++ .../CoreData/APIService+CoreData+Toot.swift | 61 ++++++++++++-- .../API/Mastodon+API+Favorites.swift | 45 ++++++++++- .../MastodonSDK/API/Mastodon+API+Polls.swift | 51 ++++++++++++ .../API/Mastodon+API+Timeline.swift | 3 +- .../MastodonSDK/API/Mastodon+API.swift | 3 + 31 files changed, 645 insertions(+), 173 deletions(-) create mode 100644 Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift delete mode 100644 Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift create mode 100644 Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift create mode 100644 Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift create mode 100644 Mastodon/Scene/Share/View/TableView/PollTableView.swift create mode 100644 Mastodon/Service/APIService/APIService+Poll.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift diff --git a/CoreDataStack/Entity/Poll.swift b/CoreDataStack/Entity/Poll.swift index 1e8b2528f..a7f6d431a 100644 --- a/CoreDataStack/Entity/Poll.swift +++ b/CoreDataStack/Entity/Poll.swift @@ -56,6 +56,34 @@ extension Poll { 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 didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + } extension Poll { diff --git a/CoreDataStack/Entity/PollOption.swift b/CoreDataStack/Entity/PollOption.swift index f0d3219d8..6c88fe609 100644 --- a/CoreDataStack/Entity/PollOption.swift +++ b/CoreDataStack/Entity/PollOption.swift @@ -50,6 +50,22 @@ extension PollOption { return option } + public func update(votesCount: Int?) { + if self.votesCount?.intValue != votesCount { + self.votesCount = votesCount.flatMap { NSNumber(value: $0) } + } + } + + public func update(votedBy: MastodonUser) { + if !(self.votedBy ?? Set()).contains(votedBy) { + self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(votedBy) + } + } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + } extension PollOption { diff --git a/Localization/app.json b/Localization/app.json index 7b118b34d..d35443b74 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -60,10 +60,15 @@ "status_content_warning": "content warning", "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" } }, diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 7509264bb..ed78ec478 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -77,7 +77,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 */; }; @@ -94,6 +94,7 @@ 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 */; }; 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 */; }; @@ -125,6 +126,9 @@ 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 */; }; 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 */; }; @@ -292,7 +296,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 = ""; }; @@ -313,6 +317,7 @@ 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 = ""; }; DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = ""; }; DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; @@ -349,6 +354,9 @@ 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 = ""; }; 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 = ""; }; @@ -551,7 +559,8 @@ children = ( 2D38F1FD25CD481700561493 /* StatusProvider.swift */, 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */, - 2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */, + 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */, + DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */, ); path = StatusProvider; sourceTree = ""; @@ -617,9 +626,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 = ""; @@ -672,6 +682,7 @@ 2D42FF7C25C82207004A627A /* ToolBar */, DB9D6C1325E4F97A0051B173 /* Container */, 2D152A8A25C295B8009AA50C /* Content */, + DB1D187125EF5BBD003F1F23 /* TableView */, 2D7631A625C1533800929FB9 /* TableviewCell */, ); path = View; @@ -752,6 +763,14 @@ path = CoreDataStack; sourceTree = ""; }; + DB1D187125EF5BBD003F1F23 /* TableView */ = { + isa = PBXGroup; + children = ( + DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */, + ); + path = TableView; + sourceTree = ""; + }; DB3D0FF725BAA68500EAA174 /* Supporting Files */ = { isa = PBXGroup; children = ( @@ -850,15 +869,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 = ""; @@ -1480,6 +1500,7 @@ 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 */, @@ -1499,6 +1520,7 @@ DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, + DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, @@ -1515,9 +1537,11 @@ 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 */, + DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift index 1ae8f34e3..ca1bbc364 100644 --- a/Mastodon/Diffiable/Item/PollItem.swift +++ b/Mastodon/Diffiable/Item/PollItem.swift @@ -15,18 +15,20 @@ enum PollItem { extension PollItem { class Attribute: Hashable { - var voted: Bool = false + // var pollVotable: Bool + var isOptionVoted: Bool - init(voted: Bool = false) { - self.voted = voted + init(isOptionVoted: Bool) { + // self.pollVotable = pollVotable + self.isOptionVoted = isOptionVoted } static func == (lhs: PollItem.Attribute, rhs: PollItem.Attribute) -> Bool { - return lhs.voted == rhs.voted + return lhs.isOptionVoted == rhs.isOptionVoted } func hash(into hasher: inout Hasher) { - hasher.combine(voted) + hasher.combine(isOptionVoted) } } } diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index 08f1f8710..de303d4a0 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -39,7 +39,6 @@ extension PollSection { itemAttribute: PollItem.Attribute ) { cell.optionLabel.text = pollOption.title - cell.configureCheckmark(state: itemAttribute.voted ? .on : .off) - + cell.configure(state: itemAttribute.isOptionVoted ? .on : .off) } } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 2fd87f4d1..2c88f7f76 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): @@ -36,7 +36,7 @@ extension StatusSection { 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) } - 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 @@ -47,7 +47,7 @@ extension StatusSection { let toot = managedObjectContext.object(with: objectID) as! Toot StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusContentWarningAttribute: 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 @@ -158,9 +158,27 @@ extension StatusSection { if let poll = (toot.reblog ?? toot).poll { cell.statusView.pollTableView.isHidden = false cell.statusView.pollStatusStackView.isHidden = false + cell.statusView.pollVoteButton.isHidden = !poll.multiple + 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) + } + } + }() let managedObjectContext = toot.managedObjectContext! - cell.statusView.statusPollTableViewDataSource = PollSection.tableViewDiffableDataSource( + cell.statusView.pollTableViewDataSource = PollSection.tableViewDiffableDataSource( for: cell.statusView.pollTableView, managedObjectContext: managedObjectContext ) @@ -171,15 +189,16 @@ extension StatusSection { .sorted(by: { $0.index.intValue < $1.index.intValue }) .map { option -> PollItem in let isVoted = (option.votedBy ?? Set()).map { $0.id }.contains(requestUserID) - let attribute = PollItem.Attribute(voted: isVoted) + let attribute = PollItem.Attribute(isOptionVoted: isVoted) let option = PollItem.opion(objectID: option.objectID, attribute: attribute) return option } snapshot.appendItems(pollItems, toSection: .main) - cell.statusView.statusPollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) } else { cell.statusView.pollTableView.isHidden = true cell.statusView.pollStatusStackView.isHidden = true + cell.statusView.pollVoteButton.isHidden = true } // toolbar diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index c049f4fca..190657e94 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -73,6 +73,8 @@ internal enum L10n { 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 { @@ -83,6 +85,16 @@ internal enum L10n { 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 { diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift new file mode 100644 index 000000000..6ef4b5f94 --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -0,0 +1,71 @@ +// +// 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) + } + +} + +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) + } + } + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift deleted file mode 100644 index 4679969e2..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.statusMosaicImageViewContainer.blurVisualEffectView.effect = nil - cell.statusView.statusMosaicImageViewContainer.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..ea222c763 --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -0,0 +1,71 @@ +// +// 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) + guard timeIntervalSinceUpdate > 60 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/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 9b1dfdf7a..a9480500f 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -18,8 +18,11 @@ "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; "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+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..fffa4b7f7 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 ) } 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..fa17319b4 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 = [] diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 55f785ca3..f6095db07 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -25,8 +25,8 @@ final class StatusView: UIView { weak var delegate: StatusViewDelegate? var isStatusTextSensitive = false - var statusPollTableViewDataSource: UITableViewDiffableDataSource? - var statusPollTableViewHeightLaoutConstraint: NSLayoutConstraint! + var pollTableViewDataSource: UITableViewDiffableDataSource? + var pollTableViewHeightLaoutConstraint: NSLayoutConstraint! let headerContainerStackView = UIStackView() @@ -105,8 +105,8 @@ final class StatusView: UIView { }() let statusMosaicImageViewContainer = MosaicImageViewContainer() - let pollTableView: UITableView = { - let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + 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 @@ -136,6 +136,15 @@ final class StatusView: UIView { 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: .regular)) + 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) + return button + }() // do not use visual effect view due to we blur text only without background let contentWarningBlurContentImageView: UIImageView = { @@ -302,18 +311,18 @@ extension StatusView { statusContainerStackView.addArrangedSubview(statusMosaicImageViewContainer) pollTableView.translatesAutoresizingMaskIntoConstraints = false statusContainerStackView.addArrangedSubview(pollTableView) - statusPollTableViewHeightLaoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1) + pollTableViewHeightLaoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1) NSLayoutConstraint.activate([ - statusPollTableViewHeightLaoutConstraint, + 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.statusPollTableViewHeightLaoutConstraint.constant = 44 + self.pollTableViewHeightLaoutConstraint.constant = 44 return } - self.statusPollTableViewHeightLaoutConstraint.constant = self.pollTableView.contentSize.height + self.pollTableViewHeightLaoutConstraint.constant = self.pollTableView.contentSize.height }) statusContainerStackView.addArrangedSubview(pollStatusStackView) @@ -321,9 +330,11 @@ extension StatusView { 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) 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 index eb427bc31..1da1246b1 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -6,6 +6,7 @@ // import UIKit +import Combine final class PollOptionTableViewCell: UITableViewCell { @@ -14,6 +15,9 @@ final class PollOptionTableViewCell: UITableViewCell { static let verticalMargin: CGFloat = 5 static let checkmarkImageSize = CGSize(width: 26, height: 26) + private var viewStateDisposeBag = Set() + private(set) var pollState: PollState = .off + let roundedBackgroundView = UIView() let checkmarkBackgroundView: UIView = { @@ -57,6 +61,22 @@ final class PollOptionTableViewCell: UITableViewCell { super.init(coder: coder) _init() } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + switch pollState { + case .off, .none: + let color = Asset.Colors.Background.systemGroupedBackground.color + self.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color + case .on: + break + } + } } @@ -113,7 +133,7 @@ extension PollOptionTableViewCell { optionPercentageLabel.setContentHuggingPriority(.required - 1, for: .horizontal) optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - configureCheckmark(state: .none) + configure(state: .none) } override func layoutSubviews() { @@ -136,13 +156,13 @@ extension PollOptionTableViewCell { extension PollOptionTableViewCell { - enum CheckmarkState { + enum PollState { case none case off case on } - func configureCheckmark(state: CheckmarkState) { + func configure(state: PollState) { switch state { case .none: checkmarkBackgroundView.backgroundColor = .clear @@ -185,13 +205,13 @@ struct PollTableViewCell_Previews: PreviewProvider { .previewLayout(.fixed(width: 375, height: 44 + 10)) UIViewPreview() { let cell = PollOptionTableViewCell() - cell.configureCheckmark(state: .off) + cell.configure(state: .off) return cell } .previewLayout(.fixed(width: 375, height: 44 + 10)) UIViewPreview() { let cell = PollOptionTableViewCell() - cell.configureCheckmark(state: .on) + cell.configure(state: .on) return cell } .previewLayout(.fixed(width: 375, height: 44 + 10)) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 326687b10..1c45bfe2d 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -9,13 +9,15 @@ import os.log import UIKit import AVKit import Combine +import CoreData +import CoreDataStack protocol StatusTableViewCellDelegate: class { + var managedObjectContext: NSManagedObjectContext { get } func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) 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) - } final class StatusTableViewCell: UITableViewCell { @@ -32,8 +34,8 @@ final class StatusTableViewCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() statusView.isStatusTextSensitive = false - statusView.pollTableView.dataSource = nil statusView.cleanUpContentWarning() + statusView.pollTableView.dataSource = nil disposeBag.removeAll() observations.removeAll() } @@ -85,12 +87,30 @@ extension StatusTableViewCell { bottomPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color statusView.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 { + guard let item = diffableDataSource.itemIdentifier(for: indexPath), + case let .opion(objectID, _) = item, + let option = delegate?.managedObjectContext.object(with: objectID) as? PollOption else { + return false + } + + return !option.poll.expired + } else { + return true + } + } +} + // MARK: - StatusViewDelegate extension StatusTableViewCell: StatusViewDelegate { func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) { 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..33944c227 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Poll.swift @@ -0,0 +1,71 @@ +// +// 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() + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift index eeb2afa2a..6868c668f 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift @@ -44,7 +44,7 @@ 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) @@ -106,10 +106,34 @@ 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 = entity.poll, let oldPoll = toot.poll, poll.options.count == oldPoll.options.count { + oldPoll.update(expiresAt: poll.expiresAt) + oldPoll.update(expired: poll.expired) + oldPoll.update(votesCount: poll.votesCount) + oldPoll.update(votersCount: poll.votersCount) + + let oldOptions = oldPoll.options.sorted(by: { $0.index.intValue < $1.index.intValue }) + for (i, (option, oldOption)) in zip(poll.options, oldOptions).enumerated() { + let votedBy: MastodonUser? = (poll.ownVotes ?? []).contains(i) ? requestMastodonUser : nil + oldOption.update(votesCount: option.votesCount) + votedBy.flatMap { oldOption.update(votedBy: $0) } + oldOption.didUpdate(at: networkDate) + } + + oldPoll.didUpdate(at: networkDate) + } + + // merge metrics if entity.favouritesCount != toot.favouritesCount.intValue { toot.update(favouritesCount:NSNumber(value: entity.favouritesCount)) } @@ -122,6 +146,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) @@ -142,10 +167,36 @@ extension APIService.CoreData { // 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) + + let oldOptions = poll.options.sorted(by: { $0.index.intValue < $1.index.intValue }) + for (i, (optionEntity, option)) in zip(entity.options, oldOptions).enumerated() { + let votedBy: MastodonUser? = (entity.ownVotes ?? []).contains(i) ? requestMastodonUser : nil + option.update(votesCount: optionEntity.votesCount) + votedBy.flatMap { option.update(votedBy: $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..54a6c7f82 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -30,6 +30,20 @@ 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 + /// + /// # 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 +56,21 @@ 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. + /// + /// # 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 +81,20 @@ 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 + /// + /// # 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) 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..6329a4403 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift @@ -0,0 +1,51 @@ +// +// 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) + } + + /// View a poll + /// + /// Using this endpoint to view the poll of status + /// + /// # 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 `Server` 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() + } + +} 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 From 028f3a9404d04e1f23f45c7ff5ff13c40ebb14d7 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 3 Mar 2021 19:34:29 +0800 Subject: [PATCH 08/15] feat: make poll cell label appearance update according to the underneath background --- .../CoreData.xcdatamodel/contents | 6 +- CoreDataStack/Entity/MastodonUser.swift | 1 + CoreDataStack/Entity/Poll.swift | 21 +++ CoreDataStack/Entity/PollOption.swift | 12 +- Localization/app.json | 3 +- Mastodon.xcodeproj/project.pbxproj | 4 + Mastodon/Diffiable/Item/PollItem.swift | 28 +++- Mastodon/Diffiable/Section/PollSection.swift | 45 ++++- .../Diffiable/Section/StatusSection.swift | 157 +++++++++++++----- Mastodon/Generated/Assets.swift | 4 + Mastodon/Generated/Strings.swift | 2 + .../Colors/Background/Poll/Contents.json | 9 + .../Poll/disabled.colorset/Contents.json | 20 +++ .../Poll/highlight.colorset/Contents.json | 20 +++ .../Resources/en.lproj/Localizable.strings | 1 + .../View/Content/VoteProgressStripView.swift | 137 +++++++++++++++ .../PollOptionTableViewCell.swift | 114 ++++++++----- .../TableviewCell/StatusTableViewCell.swift | 1 + .../CoreData/APIService+CoreData+Toot.swift | 27 +-- 19 files changed, 494 insertions(+), 118 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/disabled.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/highlight.colorset/Contents.json create mode 100644 Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 96ec6971d..3f8fe73f9 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -84,6 +84,7 @@ + @@ -105,6 +106,7 @@ + @@ -166,9 +168,9 @@ - + - + diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index 8ecf66282..dc88d48a2 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -38,6 +38,7 @@ final public class MastodonUser: NSManagedObject { @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/Poll.swift b/CoreDataStack/Entity/Poll.swift index a7f6d431a..cc5e7bbcb 100644 --- a/CoreDataStack/Entity/Poll.swift +++ b/CoreDataStack/Entity/Poll.swift @@ -26,6 +26,9 @@ public final class Poll: NSManagedObject { // one-to-many relationship @NSManaged public private(set) var options: Set + + // many-to-many relationship + @NSManaged public private(set) var votedBy: Set? } extension Poll { @@ -39,6 +42,7 @@ extension Poll { public static func insert( into context: NSManagedObjectContext, property: Property, + votedBy: MastodonUser?, options: [PollOption] ) -> Poll { let poll: Poll = context.insertObject() @@ -50,7 +54,12 @@ extension Poll { 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 @@ -80,6 +89,18 @@ extension Poll { } } + 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 } diff --git a/CoreDataStack/Entity/PollOption.swift b/CoreDataStack/Entity/PollOption.swift index 6c88fe609..14a076144 100644 --- a/CoreDataStack/Entity/PollOption.swift +++ b/CoreDataStack/Entity/PollOption.swift @@ -56,9 +56,15 @@ extension PollOption { } } - public func update(votedBy: MastodonUser) { - if !(self.votedBy ?? Set()).contains(votedBy) { - self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(votedBy) + 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) + } } } diff --git a/Localization/app.json b/Localization/app.json index d35443b74..d1b0e3c5c 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -69,7 +69,8 @@ "single": "%d voter", "multiple": "%d voters", }, - "time_left": "%s left" + "time_left": "%s left", + "closed": "Closed" } }, "timeline": { diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index ed78ec478..439696738 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -129,6 +129,7 @@ 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 /* VoteProgressStripView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F11725EFA35B001F1DAB /* VoteProgressStripView.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 */; }; @@ -357,6 +358,7 @@ 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 /* VoteProgressStripView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoteProgressStripView.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 = ""; }; @@ -525,6 +527,7 @@ isa = PBXGroup; children = ( 2D152A8B25C295CC009AA50C /* StatusView.swift */, + DB59F11725EFA35B001F1DAB /* VoteProgressStripView.swift */, ); path = Content; sourceTree = ""; @@ -1520,6 +1523,7 @@ DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, + DB59F11825EFA35B001F1DAB /* VoteProgressStripView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift index ca1bbc364..e4b0ff8df 100644 --- a/Mastodon/Diffiable/Item/PollItem.swift +++ b/Mastodon/Diffiable/Item/PollItem.swift @@ -15,20 +15,34 @@ enum PollItem { extension PollItem { class Attribute: Hashable { - // var pollVotable: Bool - var isOptionVoted: Bool - init(isOptionVoted: Bool) { - // self.pollVotable = pollVotable - self.isOptionVoted = isOptionVoted + enum SelectState: Equatable, Hashable { + case none + case off + case on + } + + enum VoteState: Equatable, Hashable { + case hidden + case reveal(voted: Bool, percentage: Double) + } + + 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.isOptionVoted == rhs.isOptionVoted + return lhs.selectState == rhs.selectState && + lhs.voteState == rhs.voteState } func hash(into hasher: inout Hasher) { - hasher.combine(isOptionVoted) + hasher.combine(selectState) + hasher.combine(voteState) } } } diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index de303d4a0..eff868a79 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -39,6 +39,49 @@ extension PollSection { itemAttribute: PollItem.Attribute ) { cell.optionLabel.text = pollOption.title - cell.configure(state: itemAttribute.isOptionVoted ? .on : .off) + configure(cell: cell, selectState: itemAttribute.selectState) + configure(cell: cell, voteState: itemAttribute.voteState) } } + +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 + } + + cell.selectState = state + } + + static func configure(cell: PollOptionTableViewCell, voteState state: PollItem.Attribute.VoteState) { + switch state { + case .hidden: + cell.optionPercentageLabel.isHidden = true + case .reveal(let voted, let percentage): + cell.optionPercentageLabel.isHidden = false + cell.optionPercentageLabel.text = String(Int(100 * percentage)) + "%" + cell.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color + cell.voteProgressStripView.progress.send(CGFloat(percentage)) + } + cell.voteState = state + + cell.layoutIfNeeded() + cell.updateTextAppearance() + } + +} diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 2c88f7f76..38e6a2b68 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -66,6 +66,9 @@ extension StatusSection { } } } +} + +extension StatusSection { static func configure( cell: StatusTableViewCell, @@ -155,52 +158,20 @@ extension StatusSection { cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 // set poll - if let poll = (toot.reblog ?? toot).poll { - cell.statusView.pollTableView.isHidden = false - cell.statusView.pollStatusStackView.isHidden = false - cell.statusView.pollVoteButton.isHidden = !poll.multiple - 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) - } + let poll = (toot.reblog ?? toot).poll + configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, poll: poll, requestUserID: requestUserID) + 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, timestampUpdatePublisher: timestampUpdatePublisher, poll: newPoll, requestUserID: requestUserID) } - }() - - let managedObjectContext = toot.managedObjectContext! - 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 isVoted = (option.votedBy ?? Set()).map { $0.id }.contains(requestUserID) - let attribute = PollItem.Attribute(isOptionVoted: isVoted) - 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) - } else { - cell.statusView.pollTableView.isHidden = true - cell.statusView.pollStatusStackView.isHidden = true - cell.statusView.pollVoteButton.isHidden = true + .store(in: &cell.disposeBag) } - + // toolbar let replyCountTitle: String = { let count = (toot.reblog ?? toot).repliesCount?.intValue ?? 0 @@ -244,6 +215,104 @@ extension StatusSection { } .store(in: &cell.disposeBag) } + + static func configure( + cell: StatusTableViewCell, + timestampUpdatePublisher: AnyPublisher, + poll: Poll?, + requestUserID: String + ) { + 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.pollVoteButton.isHidden = !poll.multiple + 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 + cell.statusView.pollTableView.allowsMultipleSelection = poll.multiple + + cell.statusView.pollTableViewDataSource = PollSection.tableViewDiffableDataSource( + for: cell.statusView.pollTableView, + managedObjectContext: managedObjectContext + ) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + let votedOptions = poll.options.filter { option in + (option.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + } + let isPollVoted = (poll.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + 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 = { + if isPollVoted { + guard !votedOptions.isEmpty else { + return .none + } + return votedOptions.contains(option) ? .on : .off + } else if poll.expired { + return .none + } else { + return .off + } + }() + let voteState: PollItem.Attribute.VoteState = { + guard isPollVoted 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) + }() + 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/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 08507ed9d..d65991fe2 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -30,6 +30,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 190657e94..c352473d2 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -69,6 +69,8 @@ internal enum L10n { 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)) 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/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index a9480500f..6071cbee3 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -17,6 +17,7 @@ "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"; diff --git a/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift b/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift new file mode 100644 index 000000000..30c9e22ca --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift @@ -0,0 +1,137 @@ +// +// VoteProgressStripView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import UIKit +import Combine + +final class VoteProgressStripView: UIView { + + var disposeBag = Set() + + private lazy var stripLayer: CAShapeLayer = { + let shapeLayer = CAShapeLayer() + shapeLayer.lineCap = .round + shapeLayer.fillColor = tintColor.cgColor + shapeLayer.strokeColor = UIColor.clear.cgColor + return shapeLayer + }() + + let progressMaskLayer: CAShapeLayer = { + let shapeLayer = CAShapeLayer() + shapeLayer.fillColor = UIColor.red.cgColor + return shapeLayer + }() + + let progress = CurrentValueSubject(0.0) + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension VoteProgressStripView { + + private func _init() { + updateLayerPath() + + layer.addSublayer(stripLayer) + + progress + .receive(on: DispatchQueue.main) + .sink { [weak self] progress in + guard let self = self else { return } + self.updateLayerPath() + } + .store(in: &disposeBag) + } + + override func layoutSubviews() { + super.layoutSubviews() + updateLayerPath() + } + +} + +extension VoteProgressStripView { + private func updateLayerPath() { + guard bounds != .zero else { return } + + stripLayer.frame = bounds + stripLayer.fillColor = tintColor.cgColor + + stripLayer.path = { + let path = UIBezierPath(roundedRect: bounds, cornerRadius: 0) + return path.cgPath + }() + + + progressMaskLayer.path = { + var rect = bounds + let newWidth = progress.value * 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) + return path.cgPath + }() + stripLayer.mask = progressMaskLayer + } + +} + + +#if DEBUG +import SwiftUI + +struct VoteProgressStripView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview() { + VoteProgressStripView() + } + .frame(width: 100, height: 44) + .padding() + .background(Color.black) + .previewLayout(.sizeThatFits) + UIViewPreview() { + let bar = VoteProgressStripView() + bar.tintColor = .white + bar.progress.value = 0.5 + return bar + } + .frame(width: 100, height: 44) + .padding() + .background(Color.black) + .previewLayout(.sizeThatFits) + UIViewPreview() { + let bar = VoteProgressStripView() + bar.tintColor = .white + bar.progress.value = 1.0 + return bar + } + .frame(width: 100, height: 44) + .padding() + .background(Color.black) + .previewLayout(.sizeThatFits) + } + } + +} +#endif diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index 1da1246b1..32f59964c 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -16,9 +16,15 @@ final class PollOptionTableViewCell: UITableViewCell { static let checkmarkImageSize = CGSize(width: 26, height: 26) private var viewStateDisposeBag = Set() - private(set) var pollState: PollState = .off + var selectState: PollItem.Attribute.SelectState = .off + var voteState: PollItem.Attribute.VoteState? let roundedBackgroundView = UIView() + let voteProgressStripView: VoteProgressStripView = { + let view = VoteProgressStripView() + view.tintColor = Asset.Colors.Background.Poll.highlight.color + return view + }() let checkmarkBackgroundView: UIView = { let view = UIView() @@ -43,6 +49,8 @@ final class PollOptionTableViewCell: UITableViewCell { return label }() + let optionLabelMiddlePaddingView = UIView() + let optionPercentageLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 13, weight: .regular) @@ -64,16 +72,26 @@ final class PollOptionTableViewCell: UITableViewCell { override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) + + guard let voteState = 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) - switch pollState { - case .off, .none: + guard let voteState = voteState else { return } + switch voteState { + case .hidden: let color = Asset.Colors.Background.systemGroupedBackground.color self.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color - case .on: + case .reveal: break } } @@ -97,6 +115,15 @@ extension PollOptionTableViewCell { 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([ @@ -123,23 +150,32 @@ extension PollOptionTableViewCell { 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: optionLabel.trailingAnchor, constant: 8), + 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) - - configure(state: .none) + optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) } override func layoutSubviews() { super.layoutSubviews() updateCornerRadius() + updateTextAppearance() } private func updateCornerRadius() { @@ -152,41 +188,35 @@ extension PollOptionTableViewCell { checkmarkBackgroundView.layer.cornerCurve = .circular } -} - -extension PollOptionTableViewCell { - - enum PollState { - case none - case off - case on - } - - func configure(state: PollState) { - switch state { - case .none: - checkmarkBackgroundView.backgroundColor = .clear - checkmarkImageView.isHidden = true - optionPercentageLabel.isHidden = true + func updateTextAppearance() { + guard let voteState = voteState else { optionLabel.textColor = Asset.Colors.Label.primary.color optionLabel.layer.removeShadow() - case .off: - checkmarkBackgroundView.backgroundColor = .systemBackground - checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor - checkmarkBackgroundView.layer.borderWidth = 1 - checkmarkImageView.isHidden = true - optionPercentageLabel.isHidden = true - optionLabel.textColor = Asset.Colors.Label.primary.color - optionLabel.layer.removeShadow() - case .on: - checkmarkBackgroundView.backgroundColor = .systemBackground - checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor - checkmarkBackgroundView.layer.borderWidth = 0 - checkmarkImageView.isHidden = false - optionPercentageLabel.isHidden = false - optionLabel.textColor = .white - optionLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) + 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() + } + } + } } @@ -205,13 +235,13 @@ struct PollTableViewCell_Previews: PreviewProvider { .previewLayout(.fixed(width: 375, height: 44 + 10)) UIViewPreview() { let cell = PollOptionTableViewCell() - cell.configure(state: .off) + PollSection.configure(cell: cell, selectState: .off) return cell } .previewLayout(.fixed(width: 375, height: 44 + 10)) UIViewPreview() { let cell = PollOptionTableViewCell() - cell.configure(state: .on) + PollSection.configure(cell: cell, selectState: .on) return cell } .previewLayout(.fixed(width: 375, height: 44 + 10)) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 1c45bfe2d..2a62d1a40 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -27,6 +27,7 @@ final class StatusTableViewCell: UITableViewCell { weak var delegate: StatusTableViewCellDelegate? var disposeBag = Set() + var pollCountdownSubscription: AnyCancellable? var observations = Set() let statusView = StatusView() diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift index 6868c668f..79fad947e 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift @@ -56,7 +56,8 @@ extension APIService.CoreData { 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 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), options: options) + 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 @@ -116,21 +117,8 @@ extension APIService.CoreData { guard networkDate > toot.updatedAt else { return } // merge poll - if let poll = entity.poll, let oldPoll = toot.poll, poll.options.count == oldPoll.options.count { - oldPoll.update(expiresAt: poll.expiresAt) - oldPoll.update(expired: poll.expired) - oldPoll.update(votesCount: poll.votesCount) - oldPoll.update(votersCount: poll.votersCount) - - let oldOptions = oldPoll.options.sorted(by: { $0.index.intValue < $1.index.intValue }) - for (i, (option, oldOption)) in zip(poll.options, oldOptions).enumerated() { - let votedBy: MastodonUser? = (poll.ownVotes ?? []).contains(i) ? requestMastodonUser : nil - oldOption.update(votesCount: option.votesCount) - votedBy.flatMap { oldOption.update(votedBy: $0) } - oldOption.didUpdate(at: networkDate) - } - - oldPoll.didUpdate(at: networkDate) + if let poll = toot.poll, let entity = entity.poll { + merge(poll: poll, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) } // merge metrics @@ -188,12 +176,15 @@ extension APIService.CoreData { 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 votedBy: MastodonUser? = (entity.ownVotes ?? []).contains(i) ? requestMastodonUser : nil + let voted: Bool = (entity.ownVotes ?? []).contains(i) option.update(votesCount: optionEntity.votesCount) - votedBy.flatMap { option.update(votedBy: $0) } + requestMastodonUser.flatMap { option.update(voted: voted, by: $0) } option.didUpdate(at: networkDate) } From 06aac878c8f371fc2bcb2710af73e8e3b757d66f Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 4 Mar 2021 18:53:29 +0800 Subject: [PATCH 09/15] feat: [WIP] make the vote poll logic works --- CoreDataStack/Entity/PollOption.swift | 2 +- Localization/app.json | 13 +- .../Diffiable/Section/StatusSection.swift | 10 +- Mastodon/Generated/Strings.swift | 12 ++ ...Provider+StatusTableViewCellDelegate.swift | 35 +++++ .../StatusProvider+UITableViewDelegate.swift | 7 +- .../Resources/en.lproj/Localizable.strings | 4 + .../View/Content/VoteProgressStripView.swift | 4 +- .../TableviewCell/StatusTableViewCell.swift | 46 ++++++- .../APIService/APIService+APIError.swift | 11 +- .../Service/APIService/APIService+Poll.swift | 124 ++++++++++++++++++ .../API/Mastodon+API+Favorites.swift | 10 ++ .../MastodonSDK/API/Mastodon+API+Polls.swift | 56 +++++++- 13 files changed, 318 insertions(+), 16 deletions(-) diff --git a/CoreDataStack/Entity/PollOption.swift b/CoreDataStack/Entity/PollOption.swift index 14a076144..f9a3ce953 100644 --- a/CoreDataStack/Entity/PollOption.swift +++ b/CoreDataStack/Entity/PollOption.swift @@ -62,7 +62,7 @@ extension PollOption { self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(by) } } else { - if !(self.votedBy ?? Set()).contains(by) { + if (self.votedBy ?? Set()).contains(by) { self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).remove(by) } } diff --git a/Localization/app.json b/Localization/app.json index d1b0e3c5c..078b68bd0 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -27,13 +27,20 @@ "ERR_INCLUSION": "is not a supported value" }, "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": { "actions": { @@ -125,7 +132,7 @@ "prompt_eight_characters": "Eight characters" }, "invite": { - "registration_user_invite_request": "Why do you want to join?" + "registration_user_invite_request": "Why do you want to join?" } }, "success": "Success", @@ -165,4 +172,4 @@ "title": "Public" } } -} +} \ No newline at end of file diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 38e6a2b68..a9c07202d 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -284,13 +284,13 @@ extension StatusSection { .map { option -> PollItem in let attribute: PollItem.Attribute = { let selectState: PollItem.Attribute.SelectState = { - if isPollVoted { - guard !votedOptions.isEmpty else { - return .none - } + // make isPollVoted check later to make only local change possible + if !votedOptions.isEmpty { return votedOptions.contains(option) ? .on : .off } else if poll.expired { return .none + } else if isPollVoted, votedOptions.isEmpty { + return .none } else { return .off } @@ -302,6 +302,8 @@ extension StatusSection { return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue) }() let voted = votedOptions.isEmpty ? true : votedOptions.contains(option) +// let voted = true +// let percentage: Double = Double.random(in: 0..<1) return .reveal(voted: voted, percentage: percentage) }() return PollItem.Attribute(selectState: selectState, voteState: voteState) diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index c352473d2..e7c411274 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 { diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 6ef4b5f94..6a4d2df62 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -39,6 +39,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } +// MARK: - MosciaImageViewContainerDelegate extension StatusTableViewCellDelegate where Self: StatusProvider { func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { @@ -69,3 +70,37 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } } + +// MARK: - PollTableView +extension StatusTableViewCellDelegate where Self: StatusProvider { + + func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) { + 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 } + + + if option.poll.multiple { + var choices: [Int] = [] + + } else { + context.apiService.vote( + pollObjectID: option.poll.objectID, + mastodonUserObjectID: activeMastodonAuthentication.user.objectID, + choices: [option.index.intValue] + ) + .receive(on: DispatchQueue.main) + .sink { completion in + + } receiveValue: { pollID in + + } + .store(in: &context.disposeBag) + + } + } + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index ea222c763..93f627c09 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -33,7 +33,12 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { return nil } let timeIntervalSinceUpdate = now.timeIntervalSince(poll.updatedAt) - guard timeIntervalSinceUpdate > 60 else { + #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 } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 6071cbee3..db613c059 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"; diff --git a/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift b/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift index 30c9e22ca..51016da3e 100644 --- a/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift +++ b/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift @@ -51,7 +51,9 @@ extension VoteProgressStripView { .receive(on: DispatchQueue.main) .sink { [weak self] progress in guard let self = self else { return } - self.updateLayerPath() + UIView.animate(withDuration: 0.33) { + self.updateLayerPath() + } } .store(in: &disposeBag) } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 2a62d1a40..e8500e05f 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -13,11 +13,15 @@ import CoreData import CoreDataStack protocol StatusTableViewCellDelegate: class { + var context: AppContext! { get} var managedObjectContext: NSManagedObjectContext { get } - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) + 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, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) + } final class StatusTableViewCell: UITableViewCell { @@ -110,6 +114,44 @@ extension StatusTableViewCell: UITableViewDelegate { return true } } + + func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + if tableView === statusView.pollTableView, let diffableDataSource = statusView.pollTableViewDataSource { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + + 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 + + // 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 + guard !option.poll.expired, !didVotedRemote, !didVotedLocal else { + return nil + } + + return indexPath + } else { + 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", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + delegate?.statusTableViewCell(self, pollTableView: statusView.pollTableView, didSelectRowAt: indexPath) + } + } + } // MARK: - StatusViewDelegate 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+Poll.swift b/Mastodon/Service/APIService/APIService+Poll.swift index 33944c227..7ee3bb029 100644 --- a/Mastodon/Service/APIService/APIService+Poll.swift +++ b/Mastodon/Service/APIService/APIService+Poll.swift @@ -69,3 +69,127 @@ extension APIService { } } + +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) + } + guard votedOptions.isEmpty else { + // if did voted. 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/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index 54a6c7f82..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") } @@ -34,6 +35,8 @@ extension Mastodon.API.Favorites { /// /// 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 @@ -60,6 +63,8 @@ extension Mastodon.API.Favorites { /// /// View who favourited a given status. /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 /// # Last Update /// 2021/3/3 /// # Reference @@ -85,6 +90,8 @@ extension Mastodon.API.Favorites { /// /// Using this endpoint to view the favourited list for user /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 /// # Last Update /// 2021/3/3 /// # Reference @@ -104,9 +111,11 @@ extension Mastodon.API.Favorites { } .eraseToAnyPublisher() } + } public extension Mastodon.API.Favorites { + enum FavoriteKind { case create case destroy @@ -144,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 index 6329a4403..8ed031413 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift @@ -14,11 +14,18 @@ extension Mastodon.API.Polls { 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 @@ -28,7 +35,7 @@ extension Mastodon.API.Polls { /// - 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 `Server` nested in the response + /// - Returns: `AnyPublisher` contains `Poll` nested in the response public static func poll( session: URLSession, domain: String, @@ -48,4 +55,51 @@ extension Mastodon.API.Polls { .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 + } + } } From 58c8eaabe826c103e0f524f61662524d3f907142 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 12:11:04 +0800 Subject: [PATCH 10/15] feat: add animation for progress bar value change --- Mastodon.xcodeproj/project.pbxproj | 16 +- Mastodon/Diffiable/Section/PollSection.swift | 2 +- .../Diffiable/Section/StatusSection.swift | 2 - .../View/Content/VoteProgressStripView.swift | 139 --------------- .../View/Control/StripProgressView.swift | 165 ++++++++++++++++++ .../PollOptionTableViewCell.swift | 4 +- 6 files changed, 180 insertions(+), 148 deletions(-) delete mode 100644 Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift create mode 100644 Mastodon/Scene/Share/View/Control/StripProgressView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 439696738..bac04cbbf 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -129,7 +129,7 @@ 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 /* VoteProgressStripView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F11725EFA35B001F1DAB /* VoteProgressStripView.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 */; }; @@ -358,7 +358,7 @@ 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 /* VoteProgressStripView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoteProgressStripView.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 = ""; }; @@ -527,7 +527,6 @@ isa = PBXGroup; children = ( 2D152A8B25C295CC009AA50C /* StatusView.swift */, - DB59F11725EFA35B001F1DAB /* VoteProgressStripView.swift */, ); path = Content; sourceTree = ""; @@ -684,6 +683,7 @@ 2D42FF8325C82245004A627A /* Button */, 2D42FF7C25C82207004A627A /* ToolBar */, DB9D6C1325E4F97A0051B173 /* Container */, + DBA9B90325F1D4420012E7B6 /* Control */, 2D152A8A25C295B8009AA50C /* Content */, DB1D187125EF5BBD003F1F23 /* TableView */, 2D7631A625C1533800929FB9 /* TableviewCell */, @@ -1119,6 +1119,14 @@ path = ViewModel; sourceTree = ""; }; + DBA9B90325F1D4420012E7B6 /* Control */ = { + isa = PBXGroup; + children = ( + DB59F11725EFA35B001F1DAB /* StripProgressView.swift */, + ); + path = Control; + sourceTree = ""; + }; DBE0821A25CD382900FD6BBD /* Register */ = { isa = PBXGroup; children = ( @@ -1523,7 +1531,7 @@ DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, - DB59F11825EFA35B001F1DAB /* VoteProgressStripView.swift in Sources */, + DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index eff868a79..54753a7a0 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -76,7 +76,7 @@ extension PollSection { cell.optionPercentageLabel.isHidden = false cell.optionPercentageLabel.text = String(Int(100 * percentage)) + "%" cell.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color - cell.voteProgressStripView.progress.send(CGFloat(percentage)) + cell.voteProgressStripView.setProgress(CGFloat(percentage), animated: true) } cell.voteState = state diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index a9c07202d..d442a23e3 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -302,8 +302,6 @@ extension StatusSection { return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue) }() let voted = votedOptions.isEmpty ? true : votedOptions.contains(option) -// let voted = true -// let percentage: Double = Double.random(in: 0..<1) return .reveal(voted: voted, percentage: percentage) }() return PollItem.Attribute(selectState: selectState, voteState: voteState) diff --git a/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift b/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift deleted file mode 100644 index 51016da3e..000000000 --- a/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// VoteProgressStripView.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-3. -// - -import UIKit -import Combine - -final class VoteProgressStripView: UIView { - - var disposeBag = Set() - - private lazy var stripLayer: CAShapeLayer = { - let shapeLayer = CAShapeLayer() - shapeLayer.lineCap = .round - shapeLayer.fillColor = tintColor.cgColor - shapeLayer.strokeColor = UIColor.clear.cgColor - return shapeLayer - }() - - let progressMaskLayer: CAShapeLayer = { - let shapeLayer = CAShapeLayer() - shapeLayer.fillColor = UIColor.red.cgColor - return shapeLayer - }() - - let progress = CurrentValueSubject(0.0) - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension VoteProgressStripView { - - private func _init() { - updateLayerPath() - - layer.addSublayer(stripLayer) - - progress - .receive(on: DispatchQueue.main) - .sink { [weak self] progress in - guard let self = self else { return } - UIView.animate(withDuration: 0.33) { - self.updateLayerPath() - } - } - .store(in: &disposeBag) - } - - override func layoutSubviews() { - super.layoutSubviews() - updateLayerPath() - } - -} - -extension VoteProgressStripView { - private func updateLayerPath() { - guard bounds != .zero else { return } - - stripLayer.frame = bounds - stripLayer.fillColor = tintColor.cgColor - - stripLayer.path = { - let path = UIBezierPath(roundedRect: bounds, cornerRadius: 0) - return path.cgPath - }() - - - progressMaskLayer.path = { - var rect = bounds - let newWidth = progress.value * 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) - return path.cgPath - }() - stripLayer.mask = progressMaskLayer - } - -} - - -#if DEBUG -import SwiftUI - -struct VoteProgressStripView_Previews: PreviewProvider { - - static var previews: some View { - Group { - UIViewPreview() { - VoteProgressStripView() - } - .frame(width: 100, height: 44) - .padding() - .background(Color.black) - .previewLayout(.sizeThatFits) - UIViewPreview() { - let bar = VoteProgressStripView() - bar.tintColor = .white - bar.progress.value = 0.5 - return bar - } - .frame(width: 100, height: 44) - .padding() - .background(Color.black) - .previewLayout(.sizeThatFits) - UIViewPreview() { - let bar = VoteProgressStripView() - bar.tintColor = .white - bar.progress.value = 1.0 - return bar - } - .frame(width: 100, height: 44) - .padding() - .background(Color.black) - .previewLayout(.sizeThatFits) - } - } - -} -#endif diff --git a/Mastodon/Scene/Share/View/Control/StripProgressView.swift b/Mastodon/Scene/Share/View/Control/StripProgressView.swift new file mode 100644 index 000000000..ae3f86735 --- /dev/null +++ b/Mastodon/Scene/Share/View/Control/StripProgressView.swift @@ -0,0 +1,165 @@ +// +// StripProgressView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import os.log +import UIKit +import Combine + +private final class StripProgressLayer: CALayer { + + var tintColor: UIColor = .black + @NSManaged var progress: CGFloat + + override class func needsDisplay(forKey key: String) -> Bool { + switch key { + case "progress": + return true + default: + return super.needsDisplay(forKey: key) + } + } + + override func display() { + let progress = 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: "progressAnimationKey") + if animated { + let animation = CABasicAnimation(keyPath: "progress") + 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: "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/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index 32f59964c..455e5fb9c 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -20,8 +20,8 @@ final class PollOptionTableViewCell: UITableViewCell { var voteState: PollItem.Attribute.VoteState? let roundedBackgroundView = UIView() - let voteProgressStripView: VoteProgressStripView = { - let view = VoteProgressStripView() + let voteProgressStripView: StripProgressView = { + let view = StripProgressView() view.tintColor = Asset.Colors.Background.Poll.highlight.color return view }() From 11cee6df357371bb612816c662ee16f0daee9fa1 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 13:41:48 +0800 Subject: [PATCH 11/15] feat: implement single vote poll --- Mastodon/Diffiable/Item/Item.swift | 8 ++--- Mastodon/Diffiable/Item/PollItem.swift | 2 +- Mastodon/Diffiable/Section/PollSection.swift | 25 +++++++------- .../Diffiable/Section/StatusSection.swift | 33 +++++++++++++------ ...Provider+StatusTableViewCellDelegate.swift | 21 ++++++++++-- .../HomeTimelineViewModel+Diffable.swift | 4 +-- .../PublicTimelineViewModel+Diffable.swift | 4 +-- .../View/Control/StripProgressView.swift | 19 ++++++++--- .../PollOptionTableViewCell.swift | 11 +++---- .../Service/APIService/APIService+Poll.swift | 2 +- 10 files changed, 81 insertions(+), 48 deletions(-) diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index c6a182b4d..645dcd7a8 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: Hashable, StatusContentWarningAttribute { + class StatusAttribute: 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/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift index e4b0ff8df..006400f9e 100644 --- a/Mastodon/Diffiable/Item/PollItem.swift +++ b/Mastodon/Diffiable/Item/PollItem.swift @@ -24,7 +24,7 @@ extension PollItem { enum VoteState: Equatable, Hashable { case hidden - case reveal(voted: Bool, percentage: Double) + case reveal(voted: Bool, percentage: Double, animated: Bool) } var selectState: SelectState diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index 54753a7a0..d7a7b43f1 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -24,7 +24,7 @@ extension PollSection { 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, itemAttribute: attribute) + PollSection.configure(cell: cell, pollOption: option, pollItemAttribute: attribute) } return cell } @@ -35,12 +35,15 @@ extension PollSection { extension PollSection { static func configure( cell: PollOptionTableViewCell, - pollOption: PollOption, - itemAttribute: PollItem.Attribute + pollOption option: PollOption, + pollItemAttribute attribute: PollItem.Attribute ) { - cell.optionLabel.text = pollOption.title - configure(cell: cell, selectState: itemAttribute.selectState) - configure(cell: cell, voteState: itemAttribute.voteState) + cell.optionLabel.text = option.title + configure(cell: cell, selectState: attribute.selectState) + configure(cell: cell, voteState: attribute.voteState) + cell.attribute = attribute + cell.layoutIfNeeded() + cell.updateTextAppearance() } } @@ -64,24 +67,18 @@ extension PollSection { cell.checkmarkBackgroundView.isHidden = false cell.checkmarkImageView.isHidden = false } - - cell.selectState = state } static func configure(cell: PollOptionTableViewCell, voteState state: PollItem.Attribute.VoteState) { switch state { case .hidden: cell.optionPercentageLabel.isHidden = true - case .reveal(let voted, let percentage): + case .reveal(let voted, let percentage, let animated): cell.optionPercentageLabel.isHidden = false cell.optionPercentageLabel.text = String(Int(100 * percentage)) + "%" cell.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color - cell.voteProgressStripView.setProgress(CGFloat(percentage), animated: true) + cell.voteProgressStripView.setProgress(CGFloat(percentage), animated: animated) } - cell.voteState = state - - cell.layoutIfNeeded() - cell.updateTextAppearance() } } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index d442a23e3..3c15996d6 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -34,7 +34,7 @@ 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 = statusTableViewCellDelegate return cell @@ -45,7 +45,7 @@ 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 = statusTableViewCellDelegate return cell @@ -76,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 @@ -99,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 = { @@ -153,13 +153,19 @@ extension StatusSection { } } cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty - let isStatusSensitive = statusContentWarningAttribute?.isStatusSensitive ?? (toot.reblog ?? toot).sensitive + 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 - configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, poll: poll, requestUserID: requestUserID) + StatusSection.configure( + cell: cell, + poll: poll, + requestUserID: requestUserID, + updateProgressAnimated: false, + timestampUpdatePublisher: timestampUpdatePublisher + ) if let poll = poll { ManagedObjectObserver.observe(object: poll) .sink { _ in @@ -167,7 +173,13 @@ extension StatusSection { } receiveValue: { change in guard case let .update(object) = change.changeType, let newPoll = object as? Poll else { return } - StatusSection.configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, poll: newPoll, requestUserID: requestUserID) + StatusSection.configure( + cell: cell, + poll: newPoll, + requestUserID: requestUserID, + updateProgressAnimated: true, + timestampUpdatePublisher: timestampUpdatePublisher + ) } .store(in: &cell.disposeBag) } @@ -218,9 +230,10 @@ extension StatusSection { static func configure( cell: StatusTableViewCell, - timestampUpdatePublisher: AnyPublisher, poll: Poll?, - requestUserID: String + requestUserID: String, + updateProgressAnimated: Bool, + timestampUpdatePublisher: AnyPublisher ) { guard let poll = poll, let managedObjectContext = poll.managedObjectContext else { @@ -302,7 +315,7 @@ extension StatusSection { return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue) }() let voted = votedOptions.isEmpty ? true : votedOptions.contains(option) - return .reveal(voted: voted, percentage: percentage) + return .reveal(voted: voted, percentage: percentage, animated: updateProgressAnimated) }() return PollItem.Attribute(selectState: selectState, voteState: voteState) }() diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 6a4d2df62..d62fb6cca 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -75,6 +75,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { extension StatusTableViewCellDelegate where Self: StatusProvider { 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 } @@ -82,24 +83,38 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard case let .opion(objectID, attribute) = item else { return } guard let option = managedObjectContext.object(with: objectID) as? PollOption else { return } + let domain = option.poll.toot.domain + let pollObjectID = option.poll.objectID if option.poll.multiple { var choices: [Int] = [] } else { + let choices = [option.index.intValue] context.apiService.vote( pollObjectID: option.poll.objectID, 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: { pollID in - + } receiveValue: { response in + print(response.value) } .store(in: &context.disposeBag) - } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index fffa4b7f7..4a34b922a 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -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/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index fa17319b4..d69da8f65 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -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/Control/StripProgressView.swift b/Mastodon/Scene/Share/View/Control/StripProgressView.swift index ae3f86735..e6550ba3d 100644 --- a/Mastodon/Scene/Share/View/Control/StripProgressView.swift +++ b/Mastodon/Scene/Share/View/Control/StripProgressView.swift @@ -11,12 +11,15 @@ 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 "progress": + case StripProgressLayer.progressKey: return true default: return super.needsDisplay(forKey: key) @@ -24,7 +27,13 @@ private final class StripProgressLayer: CALayer { } override func display() { - let progress = presentation()?.progress ?? self.progress + 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) @@ -72,15 +81,15 @@ final class StripProgressView: UIView { } func setProgress(_ progress: CGFloat, animated: Bool) { - stripProgressLayer.removeAnimation(forKey: "progressAnimationKey") + stripProgressLayer.removeAnimation(forKey: StripProgressLayer.progressAnimationKey) if animated { - let animation = CABasicAnimation(keyPath: "progress") + 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: "progressAnimationKey") + stripProgressLayer.add(animation, forKey: StripProgressLayer.progressAnimationKey) stripProgressLayer.progress = progress } else { stripProgressLayer.progress = progress diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index 455e5fb9c..7aa7ef41d 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -16,8 +16,7 @@ final class PollOptionTableViewCell: UITableViewCell { static let checkmarkImageSize = CGSize(width: 26, height: 26) private var viewStateDisposeBag = Set() - var selectState: PollItem.Attribute.SelectState = .off - var voteState: PollItem.Attribute.VoteState? + var attribute: PollItem.Attribute? let roundedBackgroundView = UIView() let voteProgressStripView: StripProgressView = { @@ -73,7 +72,7 @@ final class PollOptionTableViewCell: UITableViewCell { override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) - guard let voteState = voteState else { return } + guard let voteState = attribute?.voteState else { return } switch voteState { case .hidden: let color = Asset.Colors.Background.systemGroupedBackground.color @@ -86,7 +85,7 @@ final class PollOptionTableViewCell: UITableViewCell { override func setHighlighted(_ highlighted: Bool, animated: Bool) { super.setHighlighted(highlighted, animated: animated) - guard let voteState = voteState else { return } + guard let voteState = attribute?.voteState else { return } switch voteState { case .hidden: let color = Asset.Colors.Background.systemGroupedBackground.color @@ -189,7 +188,7 @@ extension PollOptionTableViewCell { } func updateTextAppearance() { - guard let voteState = voteState else { + guard let voteState = attribute?.voteState else { optionLabel.textColor = Asset.Colors.Label.primary.color optionLabel.layer.removeShadow() return @@ -199,7 +198,7 @@ extension PollOptionTableViewCell { case .hidden: optionLabel.textColor = Asset.Colors.Label.primary.color optionLabel.layer.removeShadow() - case .reveal(_, let percentage): + 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) diff --git a/Mastodon/Service/APIService/APIService+Poll.swift b/Mastodon/Service/APIService/APIService+Poll.swift index 7ee3bb029..350da97c8 100644 --- a/Mastodon/Service/APIService/APIService+Poll.swift +++ b/Mastodon/Service/APIService/APIService+Poll.swift @@ -135,7 +135,7 @@ extension APIService { .eraseToAnyPublisher() } - // send vote request to remote + /// send vote request to remote func vote( domain: String, pollID: Mastodon.Entity.Poll.ID, From d79666679a5cd933a5a62eaa267a4b8f7434ea4f Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 14:23:26 +0800 Subject: [PATCH 12/15] fix: poll option UI reuse issue --- Mastodon/Diffiable/Section/PollSection.swift | 3 +++ Mastodon/Diffiable/Section/StatusSection.swift | 12 ++++++++++-- .../View/TableviewCell/StatusTableViewCell.swift | 12 ++++++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index d7a7b43f1..45da63bde 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -73,9 +73,12 @@ extension PollSection { 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 3c15996d6..4863f4756 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -297,7 +297,7 @@ extension StatusSection { .map { option -> PollItem in let attribute: PollItem.Attribute = { let selectState: PollItem.Attribute.SelectState = { - // make isPollVoted check later to make only local change possible + // make isPollVoted check later to make the local change possible if !votedOptions.isEmpty { return votedOptions.contains(option) ? .on : .off } else if poll.expired { @@ -309,7 +309,15 @@ extension StatusSection { } }() let voteState: PollItem.Attribute.VoteState = { - guard isPollVoted else { return .hidden } + var needsReveal: Bool + if poll.expired { + needsReveal = true + } else if isPollVoted { + 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) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index e8500e05f..64ffb167b 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -103,12 +103,16 @@ extension StatusTableViewCell { 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 { return true @@ -117,7 +121,10 @@ extension StatusTableViewCell: UITableViewDelegate { func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { if tableView === statusView.pollTableView, let diffableDataSource = statusView.pollTableViewDataSource { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + 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 } @@ -127,6 +134,7 @@ extension StatusTableViewCell: UITableViewDelegate { 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 From 0df1a57865fa9f8675ec514fb3cd763e339997b8 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 15:53:36 +0800 Subject: [PATCH 13/15] feat: implement multiple poll --- .../Diffiable/Section/StatusSection.swift | 21 +++--- ...Provider+StatusTableViewCellDelegate.swift | 70 +++++++++++++++++-- .../Scene/Share/View/Content/StatusView.swift | 12 +++- .../TableviewCell/StatusTableViewCell.swift | 30 ++++++-- .../Service/APIService/APIService+Poll.swift | 6 +- 5 files changed, 117 insertions(+), 22 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 4863f4756..5f9d43ed5 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -245,7 +245,6 @@ extension StatusSection { cell.statusView.pollTableView.isHidden = false cell.statusView.pollStatusStackView.isHidden = false - cell.statusView.pollVoteButton.isHidden = !poll.multiple cell.statusView.pollVoteCountLabel.text = { if poll.multiple { let count = poll.votersCount?.intValue ?? 0 @@ -279,7 +278,14 @@ extension StatusSection { } cell.statusView.pollTableView.allowsSelection = !poll.expired - cell.statusView.pollTableView.allowsMultipleSelection = poll.multiple + + 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, @@ -288,21 +294,18 @@ extension StatusSection { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) - let votedOptions = poll.options.filter { option in - (option.votedBy ?? Set()).map { $0.id }.contains(requestUserID) - } - let isPollVoted = (poll.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + 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 = { - // make isPollVoted check later to make the local change possible + // 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 isPollVoted, votedOptions.isEmpty { + } else if didVotedRemote, votedOptions.isEmpty { return .none } else { return .off @@ -312,7 +315,7 @@ extension StatusSection { var needsReveal: Bool if poll.expired { needsReveal = true - } else if isPollVoted { + } else if didVotedRemote { needsReveal = true } else { needsReveal = false diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index d62fb6cca..cd4e5160d 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -74,6 +74,45 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { // 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 } @@ -83,16 +122,37 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard case let .opion(objectID, attribute) = item else { return } guard let option = managedObjectContext.object(with: objectID) as? PollOption else { return } - let domain = option.poll.toot.domain + let poll = option.poll let pollObjectID = option.poll.objectID + let domain = poll.toot.domain - if option.poll.multiple { - var choices: [Int] = [] - + 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: option.poll.objectID, + pollObjectID: pollObjectID, mastodonUserObjectID: activeMastodonAuthentication.user.objectID, choices: [option.index.intValue] ) diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index f6095db07..c1f3cb3d0 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -13,6 +13,7 @@ import AlamofireImage protocol StatusViewDelegate: class { func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) + func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) } final class StatusView: UIView { @@ -138,11 +139,12 @@ final class StatusView: UIView { }() let pollVoteButton: UIButton = { let button = HitTestExpandedButton() - button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14, weight: .regular)) + 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 }() @@ -350,6 +352,7 @@ extension StatusView { statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false contentWarningActionButton.addTarget(self, action: #selector(StatusView.contentWarningActionButtonPressed(_:)), for: .touchUpInside) + pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside) } } @@ -385,10 +388,17 @@ 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 diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 64ffb167b..13c3afba4 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -13,15 +13,16 @@ import CoreData import CoreDataStack protocol StatusTableViewCellDelegate: class { - var context: AppContext! { get} + var context: AppContext! { get } var managedObjectContext: NSManagedObjectContext { get } func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) 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 { @@ -101,6 +102,7 @@ extension StatusTableViewCell { // MARK: - UITableViewDelegate extension StatusTableViewCell: UITableViewDelegate { + func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { if tableView === statusView.pollTableView, let diffableDataSource = statusView.pollTableViewDataSource { var pollID: String? @@ -115,6 +117,7 @@ extension StatusTableViewCell: UITableViewDelegate { pollID = option.poll.id return !option.poll.expired } else { + assertionFailure() return true } } @@ -143,20 +146,31 @@ extension StatusTableViewCell: UITableViewDelegate { (option.votedBy ?? Set()).map { $0.id }.contains(userID) } let didVotedLocal = !votedOptions.isEmpty - guard !option.poll.expired, !didVotedRemote, !didVotedLocal else { - return nil + + 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", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + 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() } } @@ -164,9 +178,15 @@ extension StatusTableViewCell: UITableViewDelegate { // 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+Poll.swift b/Mastodon/Service/APIService/APIService+Poll.swift index 350da97c8..0b240466a 100644 --- a/Mastodon/Service/APIService/APIService+Poll.swift +++ b/Mastodon/Service/APIService/APIService+Poll.swift @@ -101,11 +101,13 @@ extension APIService { let votedOptions = poll.options.filter { option in (option.votedBy ?? Set()).map { $0.id }.contains(mastodonUser.id) } - guard votedOptions.isEmpty else { - // if did voted. Do not allow vote again + + 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) From 07d3c3cbff475cd27b2b0c98454a8ff436de46ee Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 15:56:20 +0800 Subject: [PATCH 14/15] chore: comment out animation logging --- Mastodon/Scene/Share/View/Control/StripProgressView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Scene/Share/View/Control/StripProgressView.swift b/Mastodon/Scene/Share/View/Control/StripProgressView.swift index e6550ba3d..710d8567d 100644 --- a/Mastodon/Scene/Share/View/Control/StripProgressView.swift +++ b/Mastodon/Scene/Share/View/Control/StripProgressView.swift @@ -34,7 +34,7 @@ private final class StripProgressLayer: CALayer { 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) + // 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 { From 4ce3f96dae39e2989862c5073b6cbceeab97dab4 Mon Sep 17 00:00:00 2001 From: CMK Date: Sat, 6 Mar 2021 13:04:30 +0800 Subject: [PATCH 15/15] chore: use setPrimitiveValue --- CoreDataStack/Entity/Application.swift | 2 +- CoreDataStack/Entity/Attachment.swift | 2 +- CoreDataStack/Entity/Emoji.swift | 2 +- CoreDataStack/Entity/History.swift | 2 +- .../Entity/MastodonAuthentication.swift | 8 +++--- CoreDataStack/Entity/Mention.swift | 3 ++- CoreDataStack/Entity/Poll.swift | 2 +- CoreDataStack/Entity/PollOption.swift | 2 +- CoreDataStack/Entity/Tag.swift | 2 +- .../xcschemes/xcschememanagement.plist | 27 +++---------------- 10 files changed, 16 insertions(+), 36 deletions(-) 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/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 index cc5e7bbcb..356f2fc2e 100644 --- a/CoreDataStack/Entity/Poll.swift +++ b/CoreDataStack/Entity/Poll.swift @@ -35,7 +35,7 @@ extension Poll { public override func awakeFromInsert() { super.awakeFromInsert() - createdAt = Date() + setPrimitiveValue(Date(), forKey: #keyPath(Poll.createdAt)) } @discardableResult diff --git a/CoreDataStack/Entity/PollOption.swift b/CoreDataStack/Entity/PollOption.swift index f9a3ce953..8917a7533 100644 --- a/CoreDataStack/Entity/PollOption.swift +++ b/CoreDataStack/Entity/PollOption.swift @@ -27,7 +27,7 @@ extension PollOption { public override func awakeFromInsert() { super.awakeFromInsert() - createdAt = Date() + setPrimitiveValue(Date(), forKey: #keyPath(PollOption.createdAt)) } @discardableResult diff --git a/CoreDataStack/Entity/Tag.swift b/CoreDataStack/Entity/Tag.swift index 3f5d2bcac..d817c774b 100644 --- a/CoreDataStack/Entity/Tag.swift +++ b/CoreDataStack/Entity/Tag.swift @@ -26,7 +26,7 @@ public final class Tag: NSManagedObject { extension Tag { public override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + setPrimitiveValue(UUID(), forKey: #keyPath(Tag.identifier)) } @discardableResult diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 7f54faa33..747fe7df0 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 - 13 + 7 Mastodon - RTL.xcscheme_^#shared#^_ @@ -22,31 +22,10 @@ Mastodon.xcscheme_^#shared#^_ orderHint - 1 + 12 SuppressBuildableAutocreation - - DB427DD125BAA00100D1B89D - - primary - - - DB427DE725BAA00100D1B89D - - primary - - - DB427DF225BAA00100D1B89D - - primary - - - DB89B9F525C10FD0008580ED - - primary - - - +