feat: implement single vote poll

This commit is contained in:
CMK 2021-03-05 13:41:48 +08:00
parent 58c8eaabe8
commit 11cee6df35
10 changed files with 81 additions and 48 deletions

View File

@ -13,10 +13,10 @@ import MastodonSDK
/// Note: update Equatable when change case /// Note: update Equatable when change case
enum Item { enum Item {
// timeline // timeline
case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusTimelineAttribute) case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusAttribute)
// normal list // normal list
case toot(objectID: NSManagedObjectID, attribute: StatusTimelineAttribute) case toot(objectID: NSManagedObjectID, attribute: StatusAttribute)
// loader // loader
case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID) case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID)
@ -30,7 +30,7 @@ protocol StatusContentWarningAttribute {
} }
extension Item { extension Item {
class StatusTimelineAttribute: Hashable, StatusContentWarningAttribute { class StatusAttribute: Hashable, StatusContentWarningAttribute {
var isStatusTextSensitive: Bool var isStatusTextSensitive: Bool
var isStatusSensitive: Bool var isStatusSensitive: Bool
@ -42,7 +42,7 @@ extension Item {
self.isStatusSensitive = isStatusSensitive 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 && return lhs.isStatusTextSensitive == rhs.isStatusTextSensitive &&
lhs.isStatusSensitive == rhs.isStatusSensitive lhs.isStatusSensitive == rhs.isStatusSensitive
} }

View File

@ -24,7 +24,7 @@ extension PollItem {
enum VoteState: Equatable, Hashable { enum VoteState: Equatable, Hashable {
case hidden case hidden
case reveal(voted: Bool, percentage: Double) case reveal(voted: Bool, percentage: Double, animated: Bool)
} }
var selectState: SelectState var selectState: SelectState

View File

@ -24,7 +24,7 @@ extension PollSection {
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PollOptionTableViewCell.self), for: indexPath) as! PollOptionTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PollOptionTableViewCell.self), for: indexPath) as! PollOptionTableViewCell
managedObjectContext.performAndWait { managedObjectContext.performAndWait {
let option = managedObjectContext.object(with: objectID) as! PollOption 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 return cell
} }
@ -35,12 +35,15 @@ extension PollSection {
extension PollSection { extension PollSection {
static func configure( static func configure(
cell: PollOptionTableViewCell, cell: PollOptionTableViewCell,
pollOption: PollOption, pollOption option: PollOption,
itemAttribute: PollItem.Attribute pollItemAttribute attribute: PollItem.Attribute
) { ) {
cell.optionLabel.text = pollOption.title cell.optionLabel.text = option.title
configure(cell: cell, selectState: itemAttribute.selectState) configure(cell: cell, selectState: attribute.selectState)
configure(cell: cell, voteState: itemAttribute.voteState) configure(cell: cell, voteState: attribute.voteState)
cell.attribute = attribute
cell.layoutIfNeeded()
cell.updateTextAppearance()
} }
} }
@ -64,24 +67,18 @@ extension PollSection {
cell.checkmarkBackgroundView.isHidden = false cell.checkmarkBackgroundView.isHidden = false
cell.checkmarkImageView.isHidden = false cell.checkmarkImageView.isHidden = false
} }
cell.selectState = state
} }
static func configure(cell: PollOptionTableViewCell, voteState state: PollItem.Attribute.VoteState) { static func configure(cell: PollOptionTableViewCell, voteState state: PollItem.Attribute.VoteState) {
switch state { switch state {
case .hidden: case .hidden:
cell.optionPercentageLabel.isHidden = true cell.optionPercentageLabel.isHidden = true
case .reveal(let voted, let percentage): case .reveal(let voted, let percentage, let animated):
cell.optionPercentageLabel.isHidden = false cell.optionPercentageLabel.isHidden = false
cell.optionPercentageLabel.text = String(Int(100 * percentage)) + "%" 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.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()
} }
} }

View File

@ -34,7 +34,7 @@ extension StatusSection {
// configure cell // configure cell
managedObjectContext.performAndWait { managedObjectContext.performAndWait {
let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex 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 cell.delegate = statusTableViewCellDelegate
return cell return cell
@ -45,7 +45,7 @@ extension StatusSection {
// configure cell // configure cell
managedObjectContext.performAndWait { managedObjectContext.performAndWait {
let toot = managedObjectContext.object(with: objectID) as! Toot 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 cell.delegate = statusTableViewCellDelegate
return cell return cell
@ -76,7 +76,7 @@ extension StatusSection {
timestampUpdatePublisher: AnyPublisher<Date, Never>, timestampUpdatePublisher: AnyPublisher<Date, Never>,
toot: Toot, toot: Toot,
requestUserID: String, requestUserID: String,
statusContentWarningAttribute: StatusContentWarningAttribute? statusItemAttribute: Item.StatusAttribute
) { ) {
// set header // set header
cell.statusView.headerContainerStackView.isHidden = toot.reblog == nil cell.statusView.headerContainerStackView.isHidden = toot.reblog == nil
@ -99,7 +99,7 @@ extension StatusSection {
// set status text content warning // set status text content warning
let spoilerText = (toot.reblog ?? toot).spoilerText ?? "" let spoilerText = (toot.reblog ?? toot).spoilerText ?? ""
let isStatusTextSensitive = statusContentWarningAttribute?.isStatusTextSensitive ?? !spoilerText.isEmpty let isStatusTextSensitive = statusItemAttribute.isStatusTextSensitive
cell.statusView.isStatusTextSensitive = isStatusTextSensitive cell.statusView.isStatusTextSensitive = isStatusTextSensitive
cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive) cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive)
cell.statusView.contentWarningTitle.text = { cell.statusView.contentWarningTitle.text = {
@ -153,13 +153,19 @@ extension StatusSection {
} }
} }
cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty 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.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil
cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0
// set poll // set poll
let poll = (toot.reblog ?? toot).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 { if let poll = poll {
ManagedObjectObserver.observe(object: poll) ManagedObjectObserver.observe(object: poll)
.sink { _ in .sink { _ in
@ -167,7 +173,13 @@ extension StatusSection {
} receiveValue: { change in } receiveValue: { change in
guard case let .update(object) = change.changeType, guard case let .update(object) = change.changeType,
let newPoll = object as? Poll else { return } 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) .store(in: &cell.disposeBag)
} }
@ -218,9 +230,10 @@ extension StatusSection {
static func configure( static func configure(
cell: StatusTableViewCell, cell: StatusTableViewCell,
timestampUpdatePublisher: AnyPublisher<Date, Never>,
poll: Poll?, poll: Poll?,
requestUserID: String requestUserID: String,
updateProgressAnimated: Bool,
timestampUpdatePublisher: AnyPublisher<Date, Never>
) { ) {
guard let poll = poll, guard let poll = poll,
let managedObjectContext = poll.managedObjectContext else { let managedObjectContext = poll.managedObjectContext else {
@ -302,7 +315,7 @@ extension StatusSection {
return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue) return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue)
}() }()
let voted = votedOptions.isEmpty ? true : votedOptions.contains(option) 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) return PollItem.Attribute(selectState: selectState, voteState: voteState)
}() }()

View File

@ -75,6 +75,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider {
extension StatusTableViewCellDelegate where Self: StatusProvider { extension StatusTableViewCellDelegate where Self: StatusProvider {
func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) { 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 activeMastodonAuthentication = context.authenticationService.activeMastodonAuthentication.value else { return }
guard let diffableDataSource = cell.statusView.pollTableViewDataSource 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 case let .opion(objectID, attribute) = item else { return }
guard let option = managedObjectContext.object(with: objectID) as? PollOption 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 { if option.poll.multiple {
var choices: [Int] = [] var choices: [Int] = []
} else { } else {
let choices = [option.index.intValue]
context.apiService.vote( context.apiService.vote(
pollObjectID: option.poll.objectID, pollObjectID: option.poll.objectID,
mastodonUserObjectID: activeMastodonAuthentication.user.objectID, mastodonUserObjectID: activeMastodonAuthentication.user.objectID,
choices: [option.index.intValue] choices: [option.index.intValue]
) )
.handleEvents(receiveOutput: { _ in
// TODO: add haptic
})
.flatMap { pollID -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Poll>, Error> in
return self.context.apiService.vote(
domain: domain,
pollID: pollID,
pollObjectID: pollObjectID,
choices: choices,
mastodonAuthenticationBox: activeMastodonAuthenticationBox
)
}
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { completion in .sink { completion in
} receiveValue: { pollID in } receiveValue: { response in
print(response.value)
} }
.store(in: &context.disposeBag) .store(in: &context.disposeBag)
} }
} }

View File

@ -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 // 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 { for item in oldSnapshot.itemIdentifiers {
guard case let .homeTimelineIndex(objectID, attribute) = item else { continue } 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 } guard let spoilerText = toot.spoilerText, !spoilerText.isEmpty else { return false }
return true 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 // append new item into snapshot
newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute)) newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute))

View File

@ -50,7 +50,7 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
return indexes.firstIndex(of: toot.id).map { index in (index, toot) } return indexes.firstIndex(of: toot.id).map { index in (index, toot) }
} }
.sorted { $0.0 < $1.0 } .sorted { $0.0 < $1.0 }
var oldSnapshotAttributeDict: [NSManagedObjectID: Item.StatusTimelineAttribute] = [:] var oldSnapshotAttributeDict: [NSManagedObjectID: Item.StatusAttribute] = [:]
for item in self.items.value { for item in self.items.value {
guard case let .toot(objectID, attribute) = item else { continue } guard case let .toot(objectID, attribute) = item else { continue }
oldSnapshotAttributeDict[objectID] = attribute oldSnapshotAttributeDict[objectID] = attribute
@ -63,7 +63,7 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate {
guard let spoilerText = targetToot.spoilerText, !spoilerText.isEmpty else { return false } guard let spoilerText = targetToot.spoilerText, !spoilerText.isEmpty else { return false }
return true 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)) items.append(Item.toot(objectID: toot.objectID, attribute: attribute))
if tootIDsWhichHasGap.contains(toot.id) { if tootIDsWhichHasGap.contains(toot.id) {
items.append(Item.publicMiddleLoader(tootID: toot.id)) items.append(Item.publicMiddleLoader(tootID: toot.id))

View File

@ -11,12 +11,15 @@ import Combine
private final class StripProgressLayer: CALayer { private final class StripProgressLayer: CALayer {
static let progressAnimationKey = "progressAnimationKey"
static let progressKey = "progress"
var tintColor: UIColor = .black var tintColor: UIColor = .black
@NSManaged var progress: CGFloat @NSManaged var progress: CGFloat
override class func needsDisplay(forKey key: String) -> Bool { override class func needsDisplay(forKey key: String) -> Bool {
switch key { switch key {
case "progress": case StripProgressLayer.progressKey:
return true return true
default: default:
return super.needsDisplay(forKey: key) return super.needsDisplay(forKey: key)
@ -24,7 +27,13 @@ private final class StripProgressLayer: CALayer {
} }
override func display() { 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) 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) UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0)
@ -72,15 +81,15 @@ final class StripProgressView: UIView {
} }
func setProgress(_ progress: CGFloat, animated: Bool) { func setProgress(_ progress: CGFloat, animated: Bool) {
stripProgressLayer.removeAnimation(forKey: "progressAnimationKey") stripProgressLayer.removeAnimation(forKey: StripProgressLayer.progressAnimationKey)
if animated { if animated {
let animation = CABasicAnimation(keyPath: "progress") let animation = CABasicAnimation(keyPath: StripProgressLayer.progressKey)
animation.fromValue = stripProgressLayer.presentation()?.progress ?? stripProgressLayer.progress animation.fromValue = stripProgressLayer.presentation()?.progress ?? stripProgressLayer.progress
animation.toValue = progress animation.toValue = progress
animation.duration = 0.33 animation.duration = 0.33
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
animation.isRemovedOnCompletion = true animation.isRemovedOnCompletion = true
stripProgressLayer.add(animation, forKey: "progressAnimationKey") stripProgressLayer.add(animation, forKey: StripProgressLayer.progressAnimationKey)
stripProgressLayer.progress = progress stripProgressLayer.progress = progress
} else { } else {
stripProgressLayer.progress = progress stripProgressLayer.progress = progress

View File

@ -16,8 +16,7 @@ final class PollOptionTableViewCell: UITableViewCell {
static let checkmarkImageSize = CGSize(width: 26, height: 26) static let checkmarkImageSize = CGSize(width: 26, height: 26)
private var viewStateDisposeBag = Set<AnyCancellable>() private var viewStateDisposeBag = Set<AnyCancellable>()
var selectState: PollItem.Attribute.SelectState = .off var attribute: PollItem.Attribute?
var voteState: PollItem.Attribute.VoteState?
let roundedBackgroundView = UIView() let roundedBackgroundView = UIView()
let voteProgressStripView: StripProgressView = { let voteProgressStripView: StripProgressView = {
@ -73,7 +72,7 @@ final class PollOptionTableViewCell: UITableViewCell {
override func setSelected(_ selected: Bool, animated: Bool) { override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated) super.setSelected(selected, animated: animated)
guard let voteState = voteState else { return } guard let voteState = attribute?.voteState else { return }
switch voteState { switch voteState {
case .hidden: case .hidden:
let color = Asset.Colors.Background.systemGroupedBackground.color let color = Asset.Colors.Background.systemGroupedBackground.color
@ -86,7 +85,7 @@ final class PollOptionTableViewCell: UITableViewCell {
override func setHighlighted(_ highlighted: Bool, animated: Bool) { override func setHighlighted(_ highlighted: Bool, animated: Bool) {
super.setHighlighted(highlighted, animated: animated) super.setHighlighted(highlighted, animated: animated)
guard let voteState = voteState else { return } guard let voteState = attribute?.voteState else { return }
switch voteState { switch voteState {
case .hidden: case .hidden:
let color = Asset.Colors.Background.systemGroupedBackground.color let color = Asset.Colors.Background.systemGroupedBackground.color
@ -189,7 +188,7 @@ extension PollOptionTableViewCell {
} }
func updateTextAppearance() { func updateTextAppearance() {
guard let voteState = voteState else { guard let voteState = attribute?.voteState else {
optionLabel.textColor = Asset.Colors.Label.primary.color optionLabel.textColor = Asset.Colors.Label.primary.color
optionLabel.layer.removeShadow() optionLabel.layer.removeShadow()
return return
@ -199,7 +198,7 @@ extension PollOptionTableViewCell {
case .hidden: case .hidden:
optionLabel.textColor = Asset.Colors.Label.primary.color optionLabel.textColor = Asset.Colors.Label.primary.color
optionLabel.layer.removeShadow() optionLabel.layer.removeShadow()
case .reveal(_, let percentage): case .reveal(_, let percentage, _):
if CGFloat(percentage) * voteProgressStripView.frame.width > optionLabelMiddlePaddingView.frame.minX { if CGFloat(percentage) * voteProgressStripView.frame.width > optionLabelMiddlePaddingView.frame.minX {
optionLabel.textColor = .white optionLabel.textColor = .white
optionLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) optionLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0)

View File

@ -135,7 +135,7 @@ extension APIService {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
// send vote request to remote /// send vote request to remote
func vote( func vote(
domain: String, domain: String,
pollID: Mastodon.Entity.Poll.ID, pollID: Mastodon.Entity.Poll.ID,