Refactor Polls to not use Core Data (#1265)

This commit is contained in:
Marcus Kida 2024-04-17 16:36:03 +02:00 committed by GitHub
parent b120d32efa
commit 24e573d9e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 399 additions and 432 deletions

View File

@ -158,54 +158,9 @@ extension StatusSection {
}()
cell.pollOptionView.viewModel.authContext = authContext
managedObjectContext.performAndWait {
guard let option = record.object(in: managedObjectContext) else {
assertionFailure()
return
}
cell.pollOptionView.configure(pollOption: option, status: statusView.viewModel.originalStatus)
// trigger update if needs
let needsUpdatePoll: Bool = {
// check first option in poll to trigger update poll only once
guard
let poll = option.poll,
option.index == 0
else { return false }
guard !poll.expired else {
return false
}
cell.pollOptionView.configure(pollOption: record)
let now = Date()
let timeIntervalSinceUpdate = now.timeIntervalSince(poll.updatedAt)
#if DEBUG
let autoRefreshTimeInterval: TimeInterval = 3 // speedup testing
#else
let autoRefreshTimeInterval: TimeInterval = 30
#endif
guard timeIntervalSinceUpdate > autoRefreshTimeInterval else {
return false
}
return true
}()
if needsUpdatePoll {
guard let poll = option.poll else { return }
let pollRecord: ManagedObjectRecord<Poll> = .init(objectID: poll.objectID)
Task { [weak context] in
guard let context = context else { return }
_ = try await context.apiService.poll(
poll: pollRecord,
authenticationBox: authContext.mastodonAuthenticationBox
)
}
}
} // end managedObjectContext.performAndWait
return cell
}
}

View File

@ -523,3 +523,70 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
} // end Task
}
}
// MARK: - poll
extension NotificationTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
func tableViewCell(
_ cell: UITableViewCell,
notificationView: NotificationView,
pollTableView tableView: UITableView,
didSelectRowAt indexPath: IndexPath
) {
guard let pollTableViewDiffableDataSource = notificationView.statusView.pollTableViewDiffableDataSource else { return }
guard let pollItem = pollTableViewDiffableDataSource.itemIdentifier(for: indexPath) else { return }
guard case let .option(pollOption) = pollItem else {
assertionFailure("only works for status data provider")
return
}
let poll = pollOption.poll
if !poll.multiple {
poll.options.forEach { $0.isSelected = false }
pollOption.isSelected = true
} else {
pollOption.isSelected.toggle()
}
}
func tableViewCell(
_ cell: UITableViewCell,
notificationView: NotificationView,
pollVoteButtonPressed button: UIButton
) {
guard let pollTableViewDiffableDataSource = notificationView.statusView.pollTableViewDiffableDataSource else { return }
guard let firstPollItem = pollTableViewDiffableDataSource.snapshot().itemIdentifiers.first else { return }
guard case let .option(firstPollOption) = firstPollItem else { return }
notificationView.statusView.viewModel.isVoting = true
Task { @MainActor in
let poll = firstPollOption.poll
let choices = poll.options
.filter { $0.isSelected == true }
.compactMap { poll.options.firstIndex(of: $0) }
do {
let newPoll = try await context.apiService.vote(
poll: poll.entity,
choices: choices,
authenticationBox: authContext.mastodonAuthenticationBox
).value
guard let entity = poll.status?.entity else { return }
let newStatus: MastodonStatus = .fromEntity(entity)
newStatus.poll = MastodonPoll(poll: newPoll, status: newStatus)
self.update(status: newStatus, intent: .pollVote)
} catch {
notificationView.statusView.viewModel.isVoting = false
}
} // end Task
}
}

View File

@ -13,6 +13,7 @@ import MastodonUI
import MastodonLocalization
import MastodonAsset
import LinkPresentation
import MastodonSDK
// MARK: - header
extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthContextProvider {
@ -263,66 +264,20 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
) {
guard let pollTableViewDiffableDataSource = statusView.pollTableViewDiffableDataSource else { return }
guard let pollItem = pollTableViewDiffableDataSource.itemIdentifier(for: indexPath) else { return }
let managedObjectContext = context.managedObjectContext
Task {
guard case let .option(pollOption) = pollItem else {
assertionFailure("only works for status data provider")
return
}
var _poll: ManagedObjectRecord<Poll>?
var _isMultiple: Bool?
var _choice: Int?
try await managedObjectContext.performChanges {
guard let pollOption = pollOption.object(in: managedObjectContext) else { return }
guard let poll = pollOption.poll else { return }
_poll = .init(objectID: poll.objectID)
_isMultiple = poll.multiple
guard !poll.isVoting else { return }
if !poll.multiple {
for option in poll.options where option != pollOption {
option.update(isSelected: false)
}
// mark voting
poll.update(isVoting: true)
// set choice
_choice = Int(pollOption.index)
}
pollOption.update(isSelected: !pollOption.isSelected)
poll.update(updatedAt: Date())
}
// Trigger vote API request for
guard let poll = _poll,
_isMultiple == false,
let choice = _choice
else { return }
do {
_ = try await context.apiService.vote(
poll: poll,
choices: [choice],
authenticationBox: authContext.mastodonAuthenticationBox
)
} catch {
// restore voting state
try await managedObjectContext.performChanges {
guard
let pollOption = pollOption.object(in: managedObjectContext),
let poll = pollOption.poll
else { return }
poll.update(isVoting: false)
}
}
} // end Task
guard case let .option(pollOption) = pollItem else {
assertionFailure("only works for status data provider")
return
}
let poll = pollOption.poll
if !poll.multiple {
poll.options.forEach { $0.isSelected = false }
pollOption.isSelected = true
} else {
pollOption.isSelected.toggle()
}
}
func tableViewCell(
@ -333,46 +288,31 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
guard let pollTableViewDiffableDataSource = statusView.pollTableViewDiffableDataSource else { return }
guard let firstPollItem = pollTableViewDiffableDataSource.snapshot().itemIdentifiers.first else { return }
guard case let .option(firstPollOption) = firstPollItem else { return }
let managedObjectContext = context.managedObjectContext
Task {
var _poll: ManagedObjectRecord<Poll>?
var _choices: [Int]?
try await managedObjectContext.performChanges {
guard let poll = firstPollOption.object(in: managedObjectContext)?.poll else { return }
_poll = .init(objectID: poll.objectID)
guard poll.multiple else { return }
// mark voting
poll.update(isVoting: true)
// set choice
_choices = poll.options
.filter { $0.isSelected }
.map { Int($0.index) }
poll.update(updatedAt: Date())
}
// Trigger vote API request for
guard let poll = _poll,
let choices = _choices
else { return }
statusView.viewModel.isVoting = true
Task { @MainActor in
let poll = firstPollOption.poll
let choices = poll.options
.filter { $0.isSelected == true }
.compactMap { poll.options.firstIndex(of: $0) }
do {
_ = try await context.apiService.vote(
poll: poll,
let newPoll = try await context.apiService.vote(
poll: poll.entity,
choices: choices,
authenticationBox: authContext.mastodonAuthenticationBox
)
).value
guard let entity = poll.status?.entity else { return }
let newStatus: MastodonStatus = .fromEntity(entity)
newStatus.poll = MastodonPoll(poll: newPoll, status: newStatus)
self.update(status: newStatus, intent: .pollVote)
} catch {
// restore voting state
try await managedObjectContext.performChanges {
guard let poll = poll.object(in: managedObjectContext) else { return }
poll.update(isVoting: false)
}
statusView.viewModel.isVoting = false
}
} // end Task

View File

@ -30,6 +30,8 @@ protocol NotificationTableViewCellDelegate: AnyObject, AutoGenerateProtocolDeleg
func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta)
func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView)
func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int)
func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath)
func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, pollVoteButtonPressed button: UIButton)
func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action)
func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton)
func tableViewCell(_ cell: UITableViewCell, notificationView: NotificationView, quoteStatusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta)
@ -70,6 +72,14 @@ extension NotificationViewDelegate where Self: NotificationViewContainerTableVie
func notificationView(_ notificationView: NotificationView, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int) {
delegate?.tableViewCell(self, notificationView: notificationView, statusView: statusView, mediaGridContainerView: mediaGridContainerView, mediaView: mediaView, didSelectMediaViewAt: index)
}
func notificationView(_ notificationView: NotificationView, statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
delegate?.tableViewCell(self, notificationView: notificationView, pollTableView: tableView, didSelectRowAt: indexPath)
}
func notificationView(_ notificationView: NotificationView, statusView: StatusView, pollVoteButtonPressed button: UIButton) {
delegate?.tableViewCell(self, notificationView: notificationView, pollVoteButtonPressed: button)
}
func notificationView(_ notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) {
delegate?.tableViewCell(self, notificationView: notificationView, statusView: statusView, actionToolbarContainer: actionToolbarContainer, buttonDidPressed: button, action: action)

View File

@ -25,7 +25,8 @@ public protocol NotificationViewDelegate: AnyObject {
func notificationView(_ notificationView: NotificationView, statusView: StatusView, metaText: MetaText, didSelectMeta meta: Meta)
func notificationView(_ notificationView: NotificationView, statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView)
func notificationView(_ notificationView: NotificationView, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaView: MediaView, didSelectMediaViewAt index: Int)
func notificationView(_ notificationView: NotificationView, statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath)
func notificationView(_ notificationView: NotificationView, statusView: StatusView, pollVoteButtonPressed button: UIButton)
func notificationView(_ notificationView: NotificationView, statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action)
func notificationView(_ notificationView: NotificationView, quoteStatusView: StatusView, authorAvatarButtonDidPressed button: AvatarButton)
@ -547,11 +548,11 @@ extension NotificationView: StatusViewDelegate {
}
public func statusView(_ statusView: StatusView, pollTableView tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
assertionFailure()
delegate?.notificationView(self, statusView: statusView, pollTableView: tableView, didSelectRowAt: indexPath)
}
public func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) {
assertionFailure()
delegate?.notificationView(self, statusView: statusView, pollVoteButtonPressed: button)
}
public func statusView(_ statusView: StatusView, actionToolbarContainer: ActionToolbarContainer, buttonDidPressed button: UIButton, action: ActionToolbarContainer.Action) {

View File

@ -14,92 +14,55 @@ import MastodonUI
import MastodonSDK
extension PollOptionView {
public func configure(pollOption option: PollOption, status: MastodonStatus?) {
guard let poll = option.poll else {
assertionFailure("PollOption to be configured is expected to be part of Poll with Status")
return
}
viewModel.objects.insert(option)
public func configure(pollOption option: MastodonPollOption) {
let poll = option.poll
let status = option.poll.status
// metaContent
option.publisher(for: \.title)
option.$title
.map { title -> MetaContent? in
return PlaintextMetaContent(string: title)
}
.assign(to: \.metaContent, on: viewModel)
.store(in: &disposeBag)
// percentage
Publishers.CombineLatest(
poll.publisher(for: \.votersCount),
option.publisher(for: \.votesCount)
poll.$votersCount,
option.$votesCount
)
.map { pollVotersCount, optionVotesCount -> Double? in
guard pollVotersCount > 0, optionVotesCount >= 0 else { return 0 }
guard let pollVotersCount, pollVotersCount > 0, let optionVotesCount, optionVotesCount >= 0 else { return 0 }
return Double(optionVotesCount) / Double(pollVotersCount)
}
.assign(to: \.percentage, on: viewModel)
.store(in: &disposeBag)
// $isExpire
poll.publisher(for: \.expired)
poll.$expired
.assign(to: \.isExpire, on: viewModel)
.store(in: &disposeBag)
// isMultiple
viewModel.isMultiple = poll.multiple
let optionIndex = option.index
let authContext = viewModel.authContext
let authorDomain = status?.entity.account.domain ?? ""
let authorID = status?.entity.account.id ?? ""
// isSelect, isPollVoted, isMyPoll
Publishers.CombineLatest4(
option.publisher(for: \.poll),
option.publisher(for: \.votedBy),
option.publisher(for: \.isSelected),
viewModel.$authContext
)
.sink { [weak self] poll, optionVotedBy, isSelected, authContext in
guard let self = self, let poll = poll else { return }
let domain = authContext?.mastodonAuthenticationBox.domain ?? ""
let userID = authContext?.mastodonAuthenticationBox.userID ?? ""
let domain = authContext?.mastodonAuthenticationBox.domain ?? ""
let userID = authContext?.mastodonAuthenticationBox.userID ?? ""
let options = poll.options
let pollVoteBy = poll.votedBy ?? Set()
let isMyPoll = authorDomain == domain
&& authorID == userID
let isMyPoll = authorDomain == domain
&& authorID == userID
self.viewModel.isSelect = option.isSelected
self.viewModel.isPollVoted = poll.voted == true
self.viewModel.isMyPoll = isMyPoll
let votedOptions = options.filter { option in
let votedBy = option.votedBy ?? Set()
return votedBy.contains(where: { $0.id == userID && $0.domain == domain })
}
let isRemoteVotedOption = votedOptions.contains(where: { $0.index == optionIndex })
let isRemoteVotedPoll = pollVoteBy.contains(where: { $0.id == userID && $0.domain == domain })
let isLocalVotedOption = isSelected
let isSelect: Bool? = {
if isLocalVotedOption {
return true
} else if !votedOptions.isEmpty {
return isRemoteVotedOption ? true : false
} else if isRemoteVotedPoll, votedOptions.isEmpty {
// the poll voted. But server not mark voted options
return nil
} else {
return false
}
}()
self.viewModel.isSelect = isSelect
self.viewModel.isPollVoted = isRemoteVotedPoll
self.viewModel.isMyPoll = isMyPoll
}
.store(in: &disposeBag)
// appearance
checkmarkBackgroundView.backgroundColor = UIColor(dynamicProvider: { trailtCollection in
return trailtCollection.userInterfaceStyle == .light ? .white : SystemTheme.tableViewCellSelectionBackgroundColor
})
checkmarkBackgroundView.backgroundColor = SystemTheme.tableViewCellSelectionBackgroundColor
}
}
@ -112,8 +75,6 @@ extension PollOptionView {
// show left-hand-side dots, otherwise view looks "incomplete"
viewModel.selectState = .off
// appearance
checkmarkBackgroundView.backgroundColor = UIColor(dynamicProvider: { trailtCollection in
return trailtCollection.userInterfaceStyle == .light ? .white : SystemTheme.tableViewCellSelectionBackgroundColor
})
checkmarkBackgroundView.backgroundColor = SystemTheme.tableViewCellSelectionBackgroundColor
}
}

View File

@ -8,6 +8,7 @@
import UIKit
import MetaTextKit
import MastodonUI
import MastodonSDK
// sourcery: protocolName = "StatusViewDelegate"
// sourcery: replaceOf = "statusView(statusView"

View File

@ -86,6 +86,8 @@ extension ThreadViewController: DataSourceProvider {
viewModel.handleEdit(status)
case .delete:
break // this case has already been handled
case .pollVote:
viewModel.handleEdit(status) // technically the data changed so refresh it to reflect the new data
}
}
}

View File

@ -72,8 +72,8 @@ final public class MastodonUser: NSManagedObject {
@NSManaged public private(set) var reblogged: Set<Status>
@NSManaged public private(set) var muted: Set<Status>
@NSManaged public private(set) var bookmarked: Set<Status>
@NSManaged public private(set) var votePollOptions: Set<PollOption>
@NSManaged public private(set) var votePolls: Set<Poll>
@NSManaged public private(set) var votePollOptions: Set<PollOptionLegacy>
@NSManaged public private(set) var votePolls: Set<PollLegacy>
// relationships
@NSManaged public private(set) var following: Set<MastodonUser>
@NSManaged public private(set) var followingBy: Set<MastodonUser>

View File

@ -8,7 +8,7 @@
import Foundation
import CoreData
public final class Poll: NSManagedObject {
public final class PollLegacy: NSManagedObject {
public typealias ID = String
// sourcery: autoGenerateProperty
@ -41,20 +41,20 @@ public final class Poll: NSManagedObject {
@NSManaged public private(set) var status: Status?
// one-to-many relationship
@NSManaged public private(set) var options: Set<PollOption>
@NSManaged public private(set) var options: Set<PollOptionLegacy>
// many-to-many relationship
@NSManaged public private(set) var votedBy: Set<MastodonUser>?
}
extension Poll {
extension PollLegacy {
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
property: Property
) -> Poll {
let object: Poll = context.insertObject()
) -> PollLegacy {
let object: PollLegacy = context.insertObject()
object.configure(property: property)
@ -63,23 +63,23 @@ extension Poll {
}
extension Poll: Managed {
extension PollLegacy: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \Poll.createdAt, ascending: false)]
return [NSSortDescriptor(keyPath: \PollLegacy.createdAt, ascending: false)]
}
}
extension Poll {
extension PollLegacy {
static func predicate(domain: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(Poll.domain), domain)
return NSPredicate(format: "%K == %@", #keyPath(PollLegacy.domain), domain)
}
static func predicate(id: ID) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(Poll.id), id)
return NSPredicate(format: "%K == %@", #keyPath(PollLegacy.id), id)
}
static func predicate(ids: [ID]) -> NSPredicate {
return NSPredicate(format: "%K IN %@", #keyPath(Poll.id), ids)
return NSPredicate(format: "%K IN %@", #keyPath(PollLegacy.id), ids)
}
public static func predicate(domain: String, id: ID) -> NSPredicate {
@ -205,7 +205,7 @@ extension Poll {
//}
// MARK: - AutoGenerateProperty
extension Poll: AutoGenerateProperty {
extension PollLegacy: AutoGenerateProperty {
// sourcery:inline:Poll.AutoGenerateProperty
// Generated using Sourcery
@ -268,7 +268,7 @@ extension Poll: AutoGenerateProperty {
}
// MARK: - AutoUpdatableObject
extension Poll: AutoUpdatableObject {
extension PollLegacy: AutoUpdatableObject {
// sourcery:inline:Poll.AutoUpdatableObject
// Generated using Sourcery
@ -308,25 +308,25 @@ extension Poll: AutoUpdatableObject {
public func update(voted: Bool, by: MastodonUser) {
if voted {
if !(votedBy ?? Set()).contains(by) {
mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(by)
mutableSetValue(forKey: #keyPath(PollLegacy.votedBy)).add(by)
}
} else {
if (votedBy ?? Set()).contains(by) {
mutableSetValue(forKey: #keyPath(Poll.votedBy)).remove(by)
mutableSetValue(forKey: #keyPath(PollLegacy.votedBy)).remove(by)
}
}
}
public func attach(options: [PollOption]) {
public func attach(options: [PollOptionLegacy]) {
for option in options {
guard !self.options.contains(option) else { continue }
self.mutableSetValue(forKey: #keyPath(Poll.options)).add(option)
self.mutableSetValue(forKey: #keyPath(PollLegacy.options)).add(option)
}
}
}
public extension Set<PollOption> {
func sortedByIndex() -> [PollOption] {
public extension Set<PollOptionLegacy> {
func sortedByIndex() -> [PollOptionLegacy] {
sorted(by: { lhs, rhs in lhs.index < rhs.index })
}
}

View File

@ -8,7 +8,7 @@
import Foundation
import CoreData
public final class PollOption: NSManagedObject {
public final class PollOptionLegacy: NSManagedObject {
// sourcery: autoGenerateProperty
@NSManaged public private(set) var index: Int64
@ -28,21 +28,21 @@ public final class PollOption: NSManagedObject {
// many-to-one relationship
// sourcery: autoUpdatableObject, autoGenerateProperty
@NSManaged public private(set) var poll: Poll?
@NSManaged public private(set) var poll: PollLegacy?
// many-to-many relationship
@NSManaged public private(set) var votedBy: Set<MastodonUser>?
}
extension PollOption {
extension PollOptionLegacy {
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
property: Property
) -> PollOption {
let object: PollOption = context.insertObject()
) -> PollOptionLegacy {
let object: PollOptionLegacy = context.insertObject()
object.configure(property: property)
@ -51,9 +51,9 @@ extension PollOption {
}
extension PollOption: Managed {
extension PollOptionLegacy: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \PollOption.createdAt, ascending: false)]
return [NSSortDescriptor(keyPath: \PollOptionLegacy.createdAt, ascending: false)]
}
}
@ -115,7 +115,7 @@ extension PollOption: Managed {
//
// MARK: - AutoGenerateProperty
extension PollOption: AutoGenerateProperty {
extension PollOptionLegacy: AutoGenerateProperty {
// sourcery:inline:PollOption.AutoGenerateProperty
// Generated using Sourcery
@ -126,7 +126,7 @@ extension PollOption: AutoGenerateProperty {
public let votesCount: Int64
public let createdAt: Date
public let updatedAt: Date
public let poll: Poll?
public let poll: PollLegacy?
public init(
index: Int64,
@ -134,7 +134,7 @@ extension PollOption: AutoGenerateProperty {
votesCount: Int64,
createdAt: Date,
updatedAt: Date,
poll: Poll?
poll: PollLegacy?
) {
self.index = index
self.title = title
@ -164,7 +164,7 @@ extension PollOption: AutoGenerateProperty {
}
// MARK: - AutoUpdatableObject
extension PollOption: AutoUpdatableObject {
extension PollOptionLegacy: AutoUpdatableObject {
// sourcery:inline:PollOption.AutoUpdatableObject
// Generated using Sourcery
@ -189,7 +189,7 @@ extension PollOption: AutoUpdatableObject {
self.isSelected = isSelected
}
}
public func update(poll: Poll?) {
public func update(poll: PollLegacy?) {
if self.poll != poll {
self.poll = poll
}
@ -199,11 +199,11 @@ extension PollOption: AutoUpdatableObject {
public func update(voted: Bool, by: MastodonUser) {
if voted {
if !(self.votedBy ?? Set()).contains(by) {
self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(by)
self.mutableSetValue(forKey: #keyPath(PollOptionLegacy.votedBy)).add(by)
}
} else {
if (self.votedBy ?? Set()).contains(by) {
self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).remove(by)
self.mutableSetValue(forKey: #keyPath(PollOptionLegacy.votedBy)).remove(by)
}
}
}

View File

@ -78,7 +78,7 @@ public final class Status: NSManagedObject {
@NSManaged public private(set) var replyTo: Status?
// sourcery: autoGenerateRelationship
@NSManaged public private(set) var poll: Poll?
@NSManaged public private(set) var poll: PollLegacy?
// sourcery: autoGenerateRelationship
@NSManaged public private(set) var card: Card?
@ -379,13 +379,13 @@ extension Status: AutoGenerateRelationship {
public struct Relationship {
public let application: Application?
public let reblog: Status?
public let poll: Poll?
public let poll: PollLegacy?
public let card: Card?
public init(
application: Application?,
reblog: Status?,
poll: Poll?,
poll: PollLegacy?,
card: Card?
) {
self.application = application

View File

@ -49,6 +49,8 @@ final public class FeedDataController {
updateReblogged(status, isReblogged)
case let .toggleSensitive(isVisible):
updateSensitive(status, isVisible)
case .pollVote:
updateEdited(status) // technically the data changed so refresh it to reflect the new data
}
}

View File

@ -9,7 +9,7 @@ import Foundation
import CoreDataStack
import MastodonSDK
extension Poll.Property {
extension PollLegacy.Property {
public init(
entity: Mastodon.Entity.Poll,
domain: String,

View File

@ -9,9 +9,9 @@ import Foundation
import MastodonSDK
import CoreDataStack
extension PollOption.Property {
extension PollOptionLegacy.Property {
public init(
poll: Poll,
poll: PollLegacy,
index: Int,
entity: Mastodon.Entity.Poll.Option,
networkDate: Date

View File

@ -53,6 +53,8 @@ public final class StatusDataController {
updateReblogged(status, isReblogged)
case let .toggleSensitive(isVisible):
updateSensitive(status, isVisible)
case .pollVote:
updateEdited(status) // technically the data changed so refresh it to reflect the new data
}
}

View File

@ -11,6 +11,6 @@ import CoreDataStack
import MastodonSDK
public enum PollItem: Hashable {
case option(record: ManagedObjectRecord<PollOption>)
case option(record: MastodonPollOption)
case history(option: Mastodon.Entity.StatusEdit.Poll.Option)
}

View File

@ -31,11 +31,11 @@ extension Persistence.Poll {
}
public struct PersistResult {
public let poll: Poll
public let poll: PollLegacy
public let isNewInsertion: Bool
public init(
poll: Poll,
poll: PollLegacy,
isNewInsertion: Bool
) {
self.poll = poll
@ -74,9 +74,9 @@ extension Persistence.Poll {
public static func fetch(
in managedObjectContext: NSManagedObjectContext,
context: PersistContext
) -> Poll? {
let request = Poll.sortedFetchRequest
request.predicate = Poll.predicate(domain: context.domain, id: context.entity.id)
) -> PollLegacy? {
let request = PollLegacy.sortedFetchRequest
request.predicate = PollLegacy.predicate(domain: context.domain, id: context.entity.id)
request.fetchLimit = 1
do {
return try managedObjectContext.fetch(request).first
@ -90,13 +90,13 @@ extension Persistence.Poll {
public static func create(
in managedObjectContext: NSManagedObjectContext,
context: PersistContext
) -> Poll {
let property = Poll.Property(
) -> PollLegacy {
let property = PollLegacy.Property(
entity: context.entity,
domain: context.domain,
networkDate: context.networkDate
)
let poll = Poll.insert(
let poll = PollLegacy.insert(
into: managedObjectContext,
property: property
)
@ -106,11 +106,11 @@ extension Persistence.Poll {
public static func merge(
in managedObjectContext: NSManagedObjectContext,
poll: Poll,
poll: PollLegacy,
context: PersistContext
) {
guard context.networkDate > poll.updatedAt else { return }
let property = Poll.Property(
let property = PollLegacy.Property(
entity: context.entity,
domain: context.domain,
networkDate: context.networkDate
@ -121,7 +121,7 @@ extension Persistence.Poll {
public static func update(
in managedObjectContext: NSManagedObjectContext,
poll: Poll,
poll: PollLegacy,
context: PersistContext
) {
let optionEntities = context.entity.options
@ -159,7 +159,7 @@ extension Persistence.Poll {
option.update(poll: nil)
managedObjectContext.delete(option)
}
var attachableOptions = [PollOption]()
var attachableOptions = [PollOptionLegacy]()
for (index, option) in context.entity.options.enumerated() {
attachableOptions.append(
Persistence.PollOption.create(
@ -180,7 +180,7 @@ extension Persistence.Poll {
poll.update(updatedAt: context.networkDate)
}
private static func needsPollOptionsUpdate(context: PersistContext, poll: Poll) -> Bool {
private static func needsPollOptionsUpdate(context: PersistContext, poll: PollLegacy) -> Bool {
let entityPollOptions = context.entity.options.map { (title: $0.title, votes: $0.votesCount) }
let pollOptions = poll.options.sortedByIndex().map { (title: $0.title, votes: Int($0.votesCount)) }

View File

@ -14,14 +14,14 @@ extension Persistence.PollOption {
public struct PersistContext {
public let index: Int
public let poll: Poll
public let poll: PollLegacy
public let entity: Mastodon.Entity.Poll.Option
public let me: MastodonUser?
public let networkDate: Date
public init(
index: Int,
poll: Poll,
poll: PollLegacy,
entity: Mastodon.Entity.Poll.Option,
me: MastodonUser?,
networkDate: Date
@ -35,11 +35,11 @@ extension Persistence.PollOption {
}
public struct PersistResult {
public let option: PollOption
public let option: PollOptionLegacy
public let isNewInsertion: Bool
public init(
option: PollOption,
option: PollOptionLegacy,
isNewInsertion: Bool
) {
self.option = option
@ -65,24 +65,24 @@ extension Persistence.PollOption {
public static func create(
in managedObjectContext: NSManagedObjectContext,
context: PersistContext
) -> PollOption {
let property = PollOption.Property(
) -> PollOptionLegacy {
let property = PollOptionLegacy.Property(
poll: context.poll,
index: context.index,
entity: context.entity,
networkDate: context.networkDate
)
let option = PollOption.insert(into: managedObjectContext, property: property)
let option = PollOptionLegacy.insert(into: managedObjectContext, property: property)
update(option: option, context: context)
return option
}
public static func merge(
option: PollOption,
option: PollOptionLegacy,
context: PersistContext
) {
guard context.networkDate > option.updatedAt else { return }
let property = PollOption.Property(
let property = PollOptionLegacy.Property(
poll: context.poll,
index: context.index,
entity: context.entity,
@ -93,7 +93,7 @@ extension Persistence.PollOption {
}
private static func update(
option: PollOption,
option: PollOptionLegacy,
context: PersistContext
) {
// Do nothing

View File

@ -78,7 +78,7 @@ extension Persistence.Status {
isNewInsertion: false
)
} else {
let poll: Poll? = {
let poll: PollLegacy? = {
guard let entity = context.entity.poll else { return nil }
let result = Persistence.Poll.createOrMerge(
in: managedObjectContext,

View File

@ -41,20 +41,6 @@ extension APIService {
hashtag: hashtag,
authorization: authorization
).singleOutput()
#warning("TODO: Remove this with IOS-181, IOS-182")
let managedObjectContext = self.backgroundManagedObjectContext
try await managedObjectContext.performChanges {
let me = authenticationBox.authentication.user(in: managedObjectContext)
for entity in response.value {
guard let poll = entity.poll else { continue }
_ = Persistence.Poll.createOrMerge(
in: managedObjectContext,
context: .init(domain: domain, entity: poll, me: me, networkDate: response.networkDate)
)
}
}
return response
}

View File

@ -40,20 +40,6 @@ extension APIService {
query: query,
authorization: authorization
).singleOutput()
#warning("TODO: Remove this with IOS-181, IOS-182")
let managedObjectContext = self.backgroundManagedObjectContext
try await managedObjectContext.performChanges {
let me = authenticationBox.authentication.user(in: managedObjectContext)
for entity in response.value {
guard let poll = entity.poll else { continue }
_ = Persistence.Poll.createOrMerge(
in: managedObjectContext,
context: .init(domain: domain, entity: poll, me: me, networkDate: response.networkDate)
)
}
}
return response
}

View File

@ -14,39 +14,18 @@ import MastodonSDK
extension APIService {
public func poll(
poll: ManagedObjectRecord<Poll>,
poll: Mastodon.Entity.Poll,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Poll> {
let authorization = authenticationBox.userAuthorization
let managedObjectContext = self.backgroundManagedObjectContext
let pollID: Poll.ID = try await managedObjectContext.perform {
guard let poll = poll.object(in: managedObjectContext) else {
throw APIError.implicit(.badRequest)
}
return poll.id
}
let response = try await Mastodon.API.Polls.poll(
session: session,
domain: authenticationBox.domain,
pollID: pollID,
pollID: poll.id,
authorization: authorization
).singleOutput()
try await managedObjectContext.performChanges {
let me = authenticationBox.authentication.user(in: managedObjectContext)
_ = Persistence.Poll.createOrMerge(
in: managedObjectContext,
context: Persistence.Poll.PersistContext(
domain: authenticationBox.domain,
entity: response.value,
me: me,
networkDate: response.networkDate
)
)
}
return response
}
@ -55,41 +34,19 @@ extension APIService {
extension APIService {
public func vote(
poll: ManagedObjectRecord<Poll>,
poll: Mastodon.Entity.Poll,
choices: [Int],
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<Mastodon.Entity.Poll> {
let managedObjectContext = backgroundManagedObjectContext
let _pollID: Poll.ID? = try await managedObjectContext.perform {
guard let poll = poll.object(in: managedObjectContext) else { return nil }
return poll.id
}
guard let pollID = _pollID else {
throw APIError.implicit(.badRequest)
}
let response = try await Mastodon.API.Polls.vote(
session: session,
domain: authenticationBox.domain,
pollID: pollID,
pollID: poll.id,
query: Mastodon.API.Polls.VoteQuery(choices: choices),
authorization: authenticationBox.userAuthorization
).singleOutput()
try await managedObjectContext.performChanges {
let me = authenticationBox.authentication.user(in: managedObjectContext)
_ = Persistence.Poll.createOrMerge(
in: managedObjectContext,
context: Persistence.Poll.PersistContext(
domain: authenticationBox.domain,
entity: response.value,
me: me,
networkDate: response.networkDate
)
)
}
return response
}

View File

@ -26,20 +26,6 @@ extension APIService {
query: query,
authorization: authorization
).singleOutput()
#warning("TODO: Remove this with IOS-181, IOS-182")
let managedObjectContext = self.backgroundManagedObjectContext
try await managedObjectContext.performChanges {
let me = authenticationBox.authentication.user(in: managedObjectContext)
for entity in response.value {
guard let poll = entity.poll else { continue }
_ = Persistence.Poll.createOrMerge(
in: managedObjectContext,
context: .init(domain: domain, entity: poll, me: me, networkDate: response.networkDate)
)
}
}
return response
} // end func

View File

@ -26,19 +26,6 @@ extension APIService {
statusID: statusID,
authorization: authorization
).singleOutput()
#warning("TODO: Remove this with IOS-181, IOS-182")
let managedObjectContext = self.backgroundManagedObjectContext
try await managedObjectContext.performChanges {
let me = authenticationBox.authentication.user(in: managedObjectContext)
if let poll = response.value.poll {
_ = Persistence.Poll.createOrMerge(
in: managedObjectContext,
context: .init(domain: domain, entity: poll, me: me, networkDate: response.networkDate)
)
}
}
return response
}

View File

@ -16,7 +16,7 @@ extension Mastodon.Entity {
/// 2021/2/24
/// # Reference
/// [Document](https://docs.joinmastodon.org/entities/poll/)
public struct Poll: Codable, Sendable {
public struct Poll: Codable, Sendable, Hashable {
public typealias ID = String
public let id: ID
@ -47,12 +47,12 @@ extension Mastodon.Entity {
}
extension Mastodon.Entity.Poll {
public struct Option: Codable, Sendable {
public struct Option: Codable, Sendable, Hashable {
public let title: String
/// nil if results are not published yet
public let votesCount: Int?
public let emojis: [Mastodon.Entity.Emoji]?
enum CodingKeys: String, CodingKey {
case title
case votesCount = "votes_count"

View File

@ -71,17 +71,29 @@ extension MastodonFeed: Hashable {
public static func == (lhs: MastodonFeed, rhs: MastodonFeed) -> Bool {
lhs.id == rhs.id &&
lhs.status?.entity == rhs.status?.entity &&
lhs.status?.poll == rhs.status?.poll &&
lhs.status?.reblog?.entity == rhs.status?.reblog?.entity &&
lhs.status?.reblog?.poll == rhs.status?.reblog?.poll &&
lhs.status?.isSensitiveToggled == rhs.status?.isSensitiveToggled &&
lhs.status?.reblog?.isSensitiveToggled == rhs.status?.reblog?.isSensitiveToggled
lhs.status?.reblog?.isSensitiveToggled == rhs.status?.reblog?.isSensitiveToggled &&
lhs.status?.poll == rhs.status?.poll &&
lhs.status?.reblog?.poll == rhs.status?.reblog?.poll &&
lhs.status?.poll?.entity == rhs.status?.poll?.entity &&
lhs.status?.reblog?.poll?.entity == rhs.status?.reblog?.poll?.entity
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(status?.entity)
hasher.combine(status?.poll)
hasher.combine(status?.reblog?.entity)
hasher.combine(status?.reblog?.poll)
hasher.combine(status?.isSensitiveToggled)
hasher.combine(status?.reblog?.isSensitiveToggled)
hasher.combine(status?.poll)
hasher.combine(status?.reblog?.poll)
hasher.combine(status?.poll?.entity)
hasher.combine(status?.reblog?.poll?.entity)
}
}

View File

@ -0,0 +1,89 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import Foundation
public final class MastodonPoll: ObservableObject, Hashable {
@Published public var votersCount: Int?
@Published public var votesCount: Int
@Published public var options: [MastodonPollOption] = []
@Published public var multiple: Bool
@Published public var expired: Bool
@Published public var expiresAt: Date?
@Published public var voted: Bool?
public var id: String {
entity.id
}
public let entity: Mastodon.Entity.Poll
public weak var status: MastodonStatus?
public init(poll: Mastodon.Entity.Poll, status: MastodonStatus?) {
self.status = status
self.entity = poll
self.votersCount = poll.votersCount
self.votesCount = poll.votesCount
self.multiple = poll.multiple
self.expired = poll.expired
self.voted = poll.voted
self.expiresAt = poll.expiresAt
self.options = poll.options.map { $0.toMastodonPollOption(with: self) }
}
public static func == (lhs: MastodonPoll, rhs: MastodonPoll) -> Bool {
lhs.entity == rhs.entity
}
public func hash(into hasher: inout Hasher) {
hasher.combine(entity)
}
}
public extension Mastodon.Entity.Poll {
func toMastodonPoll(status: MastodonStatus?) -> MastodonPoll {
return .init(poll: self, status: status)
}
}
public final class MastodonPollOption: ObservableObject, Hashable {
public let poll: MastodonPoll
public let option: Mastodon.Entity.Poll.Option
@Published public var isSelected: Bool = false
@Published public var votesCount: Int?
@Published public var title: String
@Published public var voted: Bool?
public private(set) var optionIndex: Int? = nil
public init(poll: MastodonPoll, option: Mastodon.Entity.Poll.Option, isSelected: Bool = false) {
self.poll = poll
self.option = option
self.isSelected = isSelected
self.votesCount = option.votesCount
self.title = option.title
self.optionIndex = poll.options.firstIndex(of: self)
self.voted = {
guard let ownVotes = poll.entity.ownVotes else { return false }
guard let optionIndex else { return false }
return ownVotes.contains(optionIndex)
}()
}
public static func == (lhs: MastodonPollOption, rhs: MastodonPollOption) -> Bool {
lhs.poll == rhs.poll && lhs.option == rhs.option && lhs.isSelected == rhs.isSelected
}
public func hash(into hasher: inout Hasher) {
hasher.combine(poll)
hasher.combine(option)
hasher.combine(isSelected)
}
}
public extension Mastodon.Entity.Poll.Option {
func toMastodonPollOption(with poll: MastodonPoll) -> MastodonPollOption {
return .init(poll: poll, option: self)
}
}

View File

@ -16,10 +16,16 @@ public final class MastodonStatus: ObservableObject {
@Published public var isSensitiveToggled: Bool = false
@Published public var poll: MastodonPoll?
init(entity: Mastodon.Entity.Status, isSensitiveToggled: Bool) {
self.entity = entity
self.isSensitiveToggled = isSensitiveToggled
if let poll = entity.poll {
self.poll = .init(poll: poll, status: self)
}
if let reblog = entity.reblog {
self.reblog = MastodonStatus.fromEntity(reblog)
} else {
@ -47,19 +53,30 @@ extension MastodonStatus {
originalStatus = status
return self
}
public func withPoll(_ poll: MastodonPoll?) -> MastodonStatus {
self.poll = poll
return self
}
}
extension MastodonStatus: Hashable {
public static func == (lhs: MastodonStatus, rhs: MastodonStatus) -> Bool {
lhs.entity == rhs.entity &&
lhs.poll == rhs.poll &&
lhs.entity.poll == rhs.entity.poll &&
lhs.reblog?.entity == rhs.reblog?.entity &&
lhs.reblog?.poll == rhs.reblog?.poll &&
lhs.reblog?.entity.poll == rhs.reblog?.entity.poll &&
lhs.isSensitiveToggled == rhs.isSensitiveToggled &&
lhs.reblog?.isSensitiveToggled == rhs.reblog?.isSensitiveToggled
}
public func hash(into hasher: inout Hasher) {
hasher.combine(entity)
hasher.combine(poll)
hasher.combine(reblog?.entity)
hasher.combine(reblog?.poll)
hasher.combine(isSensitiveToggled)
hasher.combine(reblog?.isSensitiveToggled)
}
@ -84,17 +101,16 @@ public extension MastodonStatus {
case toggleSensitive(Bool)
case delete
case edit
case pollVote
}
}
public extension MastodonStatus {
func getPoll(in context: NSManagedObjectContext, domain: String) async -> Poll? {
func getPoll(in domain: String, authorization: Mastodon.API.OAuth.Authorization) async -> Mastodon.Entity.Poll? {
guard
let pollId = entity.poll?.id
else { return nil }
return try? await context.perform {
let predicate = Poll.predicate(domain: domain, id: pollId)
return Poll.findOrFetch(in: context, matching: predicate)
}
let poll = try? await Mastodon.API.Polls.poll(session: .shared, domain: domain, pollID: pollId, authorization: authorization).singleOutput().value
return poll
}
}

View File

@ -281,13 +281,16 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
contentWarning = status.entity.spoilerText ?? ""
}
Task { @MainActor in
if let poll = await status.getPoll(in: context.managedObjectContext, domain: authContext.mastodonAuthenticationBox.domain) {
if let poll = await status.getPoll(
in: authContext.mastodonAuthenticationBox.domain,
authorization: authContext.mastodonAuthenticationBox.userAuthorization
) {
isPollActive = !poll.expired
pollMultipleConfigurationOption = poll.multiple
if let pollExpiresAt = poll.expiresAt {
pollExpireConfigurationOption = .init(closestDateToExpiry: pollExpiresAt)
}
pollOptions = poll.options.sortedByIndex().map {
pollOptions = poll.options.map {
let option = PollComposeItem.Option()
option.text = $0.title
return option

View File

@ -378,68 +378,44 @@ extension StatusView {
private func configurePoll(status: MastodonStatus) {
let status = status.reblog ?? status
guard
let context = viewModel.context?.managedObjectContext,
let domain = viewModel.authContext?.mastodonAuthenticationBox.domain,
let pollId = status.entity.poll?.id
else {
guard let poll = status.poll else {
return
}
let predicate = Poll.predicate(domain: domain, id: pollId)
guard let poll = Poll.findOrFetch(in: context, matching: predicate) else { return }
viewModel.managedObjects.insert(poll)
// pollItems
let options = poll.options.sorted(by: { $0.index < $1.index })
let items: [PollItem] = options.map { .option(record: .init(objectID: $0.objectID)) }
let options = poll.options
let items: [PollItem] = options.map { .option(record: $0) }
self.viewModel.pollItems = items
// isVoteButtonEnabled
poll.publisher(for: \.updatedAt)
.sink { [weak self] _ in
guard let self = self else { return }
let options = poll.options
let hasSelectedOption = options.contains(where: { $0.isSelected })
self.viewModel.isVoteButtonEnabled = hasSelectedOption
}
.store(in: &disposeBag)
// isVotable
let hasSelectedOption = options.contains(where: { $0.isSelected == true })
viewModel.isVoteButtonEnabled = hasSelectedOption
Publishers.CombineLatest(
poll.publisher(for: \.votedBy),
poll.publisher(for: \.expired)
poll.$voted,
poll.$expired
)
.map { [weak viewModel] votedBy, expired in
guard let viewModel = viewModel else { return false }
guard let authContext = viewModel.authContext else { return false }
let domain = authContext.mastodonAuthenticationBox.domain
let userID = authContext.mastodonAuthenticationBox.userID
let isVoted = votedBy?.contains(where: { $0.domain == domain && $0.id == userID }) ?? false
return !isVoted && !expired
.map { voted, expired in
return voted == false && expired == false
}
.assign(to: &viewModel.$isVotable)
// votesCount
poll.publisher(for: \.votesCount)
.map { Int($0) }
poll.$votesCount
.assign(to: \.voteCount, on: viewModel)
.store(in: &disposeBag)
// voterCount
poll.publisher(for: \.votersCount)
.map { Int($0) }
poll.$votersCount
.assign(to: \.voterCount, on: viewModel)
.store(in: &disposeBag)
// expireAt
poll.publisher(for: \.expiresAt)
poll.$expiresAt
.assign(to: \.expireAt, on: viewModel)
.store(in: &disposeBag)
// expired
poll.publisher(for: \.expired)
poll.$expired
.assign(to: \.expired, on: viewModel)
.store(in: &disposeBag)
// isVoting
poll.publisher(for: \.isVoting)
poll.$voted
.map { $0 == true }
.assign(to: \.isVoting, on: viewModel)
.store(in: &disposeBag)
}

View File

@ -457,8 +457,32 @@ extension StatusView.ViewModel {
statusView.pollTableViewHeightLayoutConstraint.constant = CGFloat(items.count) * PollOptionTableViewCell.height
statusView.setPollDisplay()
items.forEach({ item in
guard case let PollItem.option(record) = item else { return }
record.$isSelected.receive(on: DispatchQueue.main).sink { [weak self] selected in
guard let self else { return }
if (selected) {
// as we have just selected an option, the vote button must be enabled
self.isVoteButtonEnabled = true
} else {
// figure out which buttons are currently selected
let records = pollItems.compactMap({ item -> MastodonPollOption? in
guard case let PollItem.option(record) = item else { return nil }
return record
})
.filter({ $0.isSelected })
// only enable vote button if there are selected options
self.isVoteButtonEnabled = !records.isEmpty
}
statusView.pollTableView.reloadData()
}
.store(in: &self.disposeBag)
})
}
.store(in: &disposeBag)
$isVotable
.sink { isVotable in
statusView.pollTableView.allowsSelection = isVotable
@ -508,14 +532,17 @@ extension StatusView.ViewModel {
$isVotable,
$isVoting
)
.receive(on: DispatchQueue.main)
.sink { isVotable, isVoting in
guard isVotable else {
statusView.pollVoteButton.isHidden = true
statusView.pollVoteActivityIndicatorView.isHidden = true
statusView.pollTableView.isUserInteractionEnabled = false
return
}
statusView.pollVoteButton.isHidden = isVoting
statusView.pollTableView.isUserInteractionEnabled = !isVoting
statusView.pollVoteActivityIndicatorView.isHidden = !isVoting
statusView.pollVoteActivityIndicatorView.startAnimating()
}

View File

@ -12,6 +12,7 @@ import Meta
import MastodonAsset
import MastodonCore
import MastodonLocalization
import MastodonSDK
public extension CGSize {
static let authorAvatarButtonSize = CGSize(width: 46, height: 46)