feat: implement auto refresh logic for Poll

This commit is contained in:
CMK 2021-03-03 16:12:48 +08:00
parent 1e691a2a76
commit 30c035e09a
31 changed files with 645 additions and 173 deletions

View File

@ -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 {

View File

@ -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 {

View File

@ -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"
}
},

View File

@ -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 = "<group>"; };
2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = "<group>"; };
2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProviderFacade.swift; sourceTree = "<group>"; };
2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+TimelinePostTableViewCellDelegate.swift"; sourceTree = "<group>"; };
2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusTableViewCellDelegate.swift"; sourceTree = "<group>"; };
2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Favorite.swift"; sourceTree = "<group>"; };
2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectObserver.swift; sourceTree = "<group>"; };
2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = "<group>"; };
@ -313,6 +317,7 @@
DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = "<group>"; };
DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = "<group>"; };
DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = "<group>"; };
DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = "<group>"; };
DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = "<group>"; };
@ -349,6 +354,9 @@
DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = "<group>"; };
DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = "<group>"; };
DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = "<group>"; };
DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = "<group>"; };
DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = "<group>"; };
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = "<group>"; };
DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = "<group>"; };
DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = "<group>"; };
DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 = "<group>";
@ -672,6 +682,7 @@
2D42FF7C25C82207004A627A /* ToolBar */,
DB9D6C1325E4F97A0051B173 /* Container */,
2D152A8A25C295B8009AA50C /* Content */,
DB1D187125EF5BBD003F1F23 /* TableView */,
2D7631A625C1533800929FB9 /* TableviewCell */,
);
path = View;
@ -752,6 +763,14 @@
path = CoreDataStack;
sourceTree = "<group>";
};
DB1D187125EF5BBD003F1F23 /* TableView */ = {
isa = PBXGroup;
children = (
DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */,
);
path = TableView;
sourceTree = "<group>";
};
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 = "<group>";
@ -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 */,

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -21,11 +21,11 @@ extension StatusSection {
dependency: NeedsDependency,
managedObjectContext: NSManagedObjectContext,
timestampUpdatePublisher: AnyPublisher<Date, Never>,
timelinePostTableViewCellDelegate: StatusTableViewCellDelegate,
statusTableViewCellDelegate: StatusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
) -> UITableViewDiffableDataSource<StatusSection, Item> {
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

View File

@ -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 {

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Poll>, 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 {
}

View File

@ -7,13 +7,17 @@
import UIKit
import Combine
import CoreData
import CoreDataStack
protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController {
// async
func toot() -> Future<Toot?, Never>
func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Toot?, Never>
func toot(for cell: UICollectionViewCell) -> Future<Toot?, Never>
// sync
var managedObjectContext: NSManagedObjectContext { get }
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { get }
func item(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Item?, Never>
func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item?
}

View File

@ -0,0 +1,12 @@
//
// TableViewCellHeightCacheableContainer.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-3.
//
import UIKit
protocol TableViewCellHeightCacheableContainer: UIViewController {
// TODO:
}

View File

@ -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";

View File

@ -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<StatusSection, Item>? {
return viewModel.diffableDataSource
}
func item(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Item?, Never> {
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
}
}

View File

@ -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)
}
}

View File

@ -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
)
}

View File

@ -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<Toot?, Never> {
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<StatusSection, Item>? {
return viewModel.diffableDataSource
}
func item(for cell: UITableViewCell, indexPath: IndexPath?) -> Future<Item?, Never> {
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
}
}

View File

@ -76,7 +76,7 @@ extension PublicTimelineViewController {
viewModel.setupDiffableDataSource(
for: tableView,
dependency: self,
timelinePostTableViewCellDelegate: self,
statusTableViewCellDelegate: self,
timelineMiddleLoaderTableViewCellDelegate: self
)
}

View File

@ -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 = []

View File

@ -25,8 +25,8 @@ final class StatusView: UIView {
weak var delegate: StatusViewDelegate?
var isStatusTextSensitive = false
var statusPollTableViewDataSource: UITableViewDiffableDataSource<PollSection, PollItem>?
var statusPollTableViewHeightLaoutConstraint: NSLayoutConstraint!
var pollTableViewDataSource: UITableViewDiffableDataSource<PollSection, PollItem>?
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)

View File

@ -0,0 +1,10 @@
//
// PollTableView.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-3.
//
import UIKit
final class PollTableView: UITableView { }

View File

@ -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<AnyCancellable>()
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))

View File

@ -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) {

View File

@ -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" } ?? "<nil>", 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<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> in
let log = OSLog.api

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Poll>, 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<Mastodon.Response.Content<Mastodon.Entity.Poll>, 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<Mastodon.Entity.Poll> in
switch result {
case .success:
return response
case .failure(let error):
throw error
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}

View File

@ -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)
}
}

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Status>, 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<Mastodon.Response.Content<[Mastodon.Entity.Account]>, 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<Mastodon.Response.Content<[Mastodon.Entity.Account]>, 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<Mastodon.Response.Content<[Mastodon.Entity.Status]>, 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<Mastodon.Response.Content<[Mastodon.Entity.Status]>, Error> {
let url = favoritesStatusesEndpointURL(domain: domain)
let request = Mastodon.API.get(url: url, query: query, authorization: authorization)
return session.dataTaskPublisher(for: request)

View File

@ -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<Mastodon.Response.Content<Mastodon.Entity.Poll>, 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()
}
}

View File

@ -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,

View File

@ -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) ?? "<nil>")
debugPrint(decodeError)
#endif