Files
Swiftgram/submodules/TelegramUI/Sources/PollResultsController.swift
2026-02-24 15:10:22 +04:00

545 lines
24 KiB
Swift

import Foundation
import UIKit
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import AccountContext
import ItemListUI
import Display
import ItemListPeerItem
import ItemListPeerActionItem
import TextFormat
import TelegramStringFormatting
private let collapsedResultCount: Int = 10
private let collapsedInitialLimit: Int = 10
private final class PollResultsControllerArguments {
let context: AccountContext
let message: EngineMessage
let collapseOption: (Data) -> Void
let expandOption: (Data) -> Void
let openPeer: (EngineRenderedPeer) -> Void
let expandSolution: () -> Void
init(context: AccountContext, message: EngineMessage, collapseOption: @escaping (Data) -> Void, expandOption: @escaping (Data) -> Void, openPeer: @escaping (EngineRenderedPeer) -> Void, expandSolution: @escaping () -> Void) {
self.context = context
self.message = message
self.collapseOption = collapseOption
self.expandOption = expandOption
self.openPeer = openPeer
self.expandSolution = expandSolution
}
}
private enum PollResultsSection {
case text
case solution
case option(Int)
var rawValue: Int32 {
switch self {
case .text:
return 0
case .solution:
return 1
case let .option(index):
return 2 + Int32(index)
}
}
}
private enum PollResultsEntryId: Hashable {
case text
case optionPeer(Int, Int)
case optionExpand(Int)
case solutionHeader
case solutionText
}
private enum PollResultsItemTag: ItemListItemTag, Equatable {
case firstOptionPeer(opaqueIdentifier: Data)
func isEqual(to other: ItemListItemTag) -> Bool {
if let other = other as? PollResultsItemTag, self == other {
return true
} else {
return false
}
}
}
private enum PollResultsEntry: ItemListNodeEntry {
case text(String, [MessageTextEntity])
case optionPeer(optionId: Int, index: Int, peer: EngineRenderedPeer, timestamp: Int32?, optionText: String, optionTextEntities: [MessageTextEntity], optionAdditionalText: String, optionCount: Int32, optionExpanded: Bool, opaqueIdentifier: Data, shimmeringAlternation: Int?, isFirstInOption: Bool)
case optionExpand(optionId: Int, opaqueIdentifier: Data, text: String, enabled: Bool)
case solutionHeader(String)
case solutionText(String, [MessageTextEntity])
var section: ItemListSectionId {
switch self {
case .text:
return PollResultsSection.text.rawValue
case let .optionPeer(optionId, _, _, _, _, _, _, _, _, _, _, _):
return PollResultsSection.option(optionId).rawValue
case let .optionExpand(optionId, _, _, _):
return PollResultsSection.option(optionId).rawValue
case .solutionHeader, .solutionText:
return PollResultsSection.solution.rawValue
}
}
var stableId: PollResultsEntryId {
switch self {
case .text:
return .text
case let .optionPeer(optionId, index, _, _, _, _, _, _, _, _, _, _):
return .optionPeer(optionId, index)
case let .optionExpand(optionId, _, _, _):
return .optionExpand(optionId)
case .solutionHeader:
return .solutionHeader
case .solutionText:
return .solutionText
}
}
static func <(lhs: PollResultsEntry, rhs: PollResultsEntry) -> Bool {
switch lhs {
case .text:
switch rhs {
case .text:
return false
default:
return true
}
case .solutionHeader:
switch rhs {
case .text:
return false
case .solutionHeader:
return false
default:
return true
}
case .solutionText:
switch rhs {
case .text:
return false
case .solutionHeader:
return false
case .solutionText:
return false
default:
return true
}
case let .optionPeer(lhsOptionId, lhsIndex, _, _, _, _, _, _, _, _, _, _):
switch rhs {
case .text:
return false
case .solutionHeader:
return false
case .solutionText:
return false
case let .optionPeer(rhsOptionId, rhsIndex, _, _, _, _, _, _, _, _, _, _):
if lhsOptionId == rhsOptionId {
return lhsIndex < rhsIndex
} else {
return lhsOptionId < rhsOptionId
}
case let .optionExpand(rhsOptionId, _, _, _):
if lhsOptionId == rhsOptionId {
return true
} else {
return lhsOptionId < rhsOptionId
}
}
case let .optionExpand(lhsOptionId, _, _, _):
switch rhs {
case .text:
return false
case .solutionHeader:
return false
case .solutionText:
return false
case let .optionPeer(rhsOptionId, _, _, _, _, _, _, _, _, _, _, _):
if lhsOptionId == rhsOptionId {
return false
} else {
return lhsOptionId < rhsOptionId
}
case let .optionExpand(rhsOptionId, _, _, _):
if lhsOptionId == rhsOptionId {
return false
} else {
return lhsOptionId < rhsOptionId
}
}
}
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let arguments = arguments as! PollResultsControllerArguments
switch self {
case let .text(text, entities):
let font = Font.semibold(presentationData.fontSize.itemListBaseFontSize)
var entityFiles: [EngineMedia.Id: TelegramMediaFile] = [:]
for (id, media) in arguments.message.associatedMedia {
if let file = media as? TelegramMediaFile {
entityFiles[id] = file
}
}
let attributedText = stringWithAppliedEntities(
text,
entities: entities.filter { entity in
if case .CustomEmoji = entity.type {
return true
} else {
return false
}
},
baseColor: presentationData.theme.list.freeTextColor,
linkColor: presentationData.theme.list.freeTextColor,
baseQuoteTintColor: nil,
baseQuoteSecondaryTintColor: nil,
baseQuoteTertiaryTintColor: nil,
codeBlockTitleColor: nil,
codeBlockAccentColor: nil,
codeBlockBackgroundColor: nil,
baseFont: font,
linkFont: font,
boldFont: font,
italicFont: font,
boldItalicFont: font,
fixedFont: font,
blockQuoteFont: font,
underlineLinks: false,
external: false,
message: arguments.message._asMessage(),
entityFiles: entityFiles,
adjustQuoteFontSize: false,
cachedMessageSyntaxHighlight: nil
)
return ItemListTextItem(presentationData: presentationData, text: .custom(context: arguments.context, string: attributedText), sectionId: self.section, additionalInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: -6.0, right: 0.0), additionalOuterInsets: UIEdgeInsets(top: 14.0, left: 0.0, bottom: 0.0, right: 0.0))
case let .solutionHeader(text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case let .solutionText(text, entities):
let _ = entities
//TODO:release
return ItemListMultilineTextItem(presentationData: presentationData, text: text, enabledEntityTypes: [], sectionId: self.section, style: .blocks)
case let .optionPeer(optionId, _, peer, timestamp, optionText, optionTextEntities, optionAdditionalText, optionCount, optionExpanded, opaqueIdentifier, shimmeringAlternation, isFirstInOption):
let font = Font.regular(13.0)
var entityFiles: [EngineMedia.Id: TelegramMediaFile] = [:]
for (id, media) in arguments.message.associatedMedia {
if let file = media as? TelegramMediaFile {
entityFiles[id] = file
}
}
let attributedText = stringWithAppliedEntities(
optionText,
entities: optionTextEntities.filter { entity in
if case .CustomEmoji = entity.type {
return true
} else {
return false
}
},
baseColor: presentationData.theme.list.freeTextColor,
linkColor: presentationData.theme.list.freeTextColor,
baseQuoteTintColor: nil,
baseQuoteSecondaryTintColor: nil,
baseQuoteTertiaryTintColor: nil,
codeBlockTitleColor: nil,
codeBlockAccentColor: nil,
codeBlockBackgroundColor: nil,
baseFont: font,
linkFont: font,
boldFont: font,
italicFont: font,
boldItalicFont: font,
fixedFont: font,
blockQuoteFont: font,
underlineLinks: false,
external: false,
message: arguments.message._asMessage(),
entityFiles: entityFiles,
adjustQuoteFontSize: false,
cachedMessageSyntaxHighlight: nil
)
let header = ItemListPeerItemHeader(theme: presentationData.theme, strings: presentationData.strings, context: arguments.context, text: attributedText, additionalText: optionAdditionalText, actionTitle: optionExpanded ? presentationData.strings.PollResults_Collapse : presentationData.strings.MessagePoll_VotedCount(optionCount), id: Int64(optionId), action: optionExpanded ? {
arguments.collapseOption(opaqueIdentifier)
} : nil)
let label: ItemListPeerItemLabel
if let timestamp {
let (timeString, dateString) = formatTimeAndDate(timestamp: timestamp, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat)
let labelString = NSMutableAttributedString()
if let dateString {
labelString.append(NSAttributedString(string: "\(dateString)\n", font: Font.regular(presentationData.fontSize.itemListBaseFontSize * 13.0 / 17.0), textColor: presentationData.theme.list.itemSecondaryTextColor))
}
labelString.append(NSAttributedString(string: "\(timeString)", font: Font.regular(presentationData.fontSize.itemListBaseFontSize * 13.0 / 17.0), textColor: presentationData.theme.list.itemPrimaryTextColor))
label = .attributedText(labelString)
} else {
label = .none
}
return ItemListPeerItem(presentationData: presentationData, systemStyle: .glass, dateTimeFormat: PresentationDateTimeFormat(), nameDisplayOrder: .firstLast, context: arguments.context, peer: peer.peers[peer.peerId]!, presence: nil, text: .none, label: label, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: false), switchValue: nil, enabled: true, selectable: shimmeringAlternation == nil, sectionId: self.section, action: {
arguments.openPeer(peer)
}, setPeerIdWithRevealedOptions: { _, _ in
}, removePeer: { _ in
}, noInsets: true, tag: isFirstInOption ? PollResultsItemTag.firstOptionPeer(opaqueIdentifier: opaqueIdentifier) : nil, header: header, shimmering: shimmeringAlternation.flatMap { ItemListPeerItemShimmering(alternationIndex: $0) })
case let .optionExpand(_, opaqueIdentifier, text, enabled):
return ItemListPeerActionItem(presentationData: presentationData, systemStyle: .glass, icon: PresentationResourcesItemList.downArrowImage(presentationData.theme), title: text, sectionId: self.section, editing: false, action: enabled ? {
arguments.expandOption(opaqueIdentifier)
} : nil)
}
}
}
private struct PollResultsControllerState: Equatable {
var expandedOptions: [Data: Int] = [:]
var isSolutionExpanded: Bool = false
}
private func pollResultsControllerEntries(presentationData: PresentationData, message: EngineMessage, poll: TelegramMediaPoll, state: PollResultsControllerState, resultsState: PollResultsState) -> [PollResultsEntry] {
var entries: [PollResultsEntry] = []
var isEmpty = false
for (_, optionState) in resultsState.options {
if !optionState.hasLoadedOnce {
isEmpty = true
break
}
}
entries.append(.text(poll.text, poll.textEntities))
var optionVoterCount: [Int: Int32] = [:]
let totalVoterCount = poll.results.totalVoters ?? 0
var optionPercentage: [Int] = []
if totalVoterCount != 0 {
if let voters = poll.results.voters, let _ = poll.results.totalVoters {
for i in 0 ..< poll.options.count {
inner: for optionVoters in voters {
if optionVoters.opaqueIdentifier == poll.options[i].opaqueIdentifier {
optionVoterCount[i] = optionVoters.count
break inner
}
}
}
}
optionPercentage = countNicePercent(votes: (0 ..< poll.options.count).map({ Int(optionVoterCount[$0] ?? 0) }), total: Int(totalVoterCount))
}
for i in 0 ..< poll.options.count {
let percentage = optionPercentage.count > i ? optionPercentage[i] : 0
let option = poll.options[i]
let optionTextHeader = option.text.uppercased()
let optionTextHeaderEntities = option.entities
let optionAdditionalTextHeader = "\(percentage)%"
if isEmpty {
if let voterCount = optionVoterCount[i], voterCount != 0 {
let displayCount: Int
if Int(voterCount) > collapsedInitialLimit {
displayCount = collapsedResultCount
} else {
displayCount = Int(voterCount)
}
for peerIndex in 0 ..< displayCount {
let fakeUser = TelegramUser(id: EnginePeer.Id(namespace: .max, id: EnginePeer.Id.Id._internalFromInt64Value(0)), accessHash: nil, firstName: "", lastName: "", username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil)
let peer = EngineRenderedPeer(peer: EnginePeer(fakeUser))
entries.append(.optionPeer(optionId: i, index: peerIndex, peer: peer, timestamp: nil, optionText: optionTextHeader, optionTextEntities: optionTextHeaderEntities, optionAdditionalText: optionAdditionalTextHeader, optionCount: voterCount, optionExpanded: false, opaqueIdentifier: option.opaqueIdentifier, shimmeringAlternation: peerIndex % 2, isFirstInOption: peerIndex == 0))
}
if displayCount < Int(voterCount) {
let remainingCount = Int(voterCount) - displayCount
entries.append(.optionExpand(optionId: i, opaqueIdentifier: option.opaqueIdentifier, text: presentationData.strings.PollResults_ShowMore(Int32(remainingCount)), enabled: false))
}
}
} else {
if let optionState = resultsState.options[option.opaqueIdentifier], !optionState.peers.isEmpty {
let optionExpandedAtCount = state.expandedOptions[option.opaqueIdentifier]
let peers = optionState.peers
let count = optionState.count
let displayCount: Int
if peers.count > collapsedInitialLimit {
if optionExpandedAtCount != nil {
displayCount = peers.count
} else {
displayCount = collapsedResultCount
}
} else {
if let optionExpandedAtCount = optionExpandedAtCount {
if optionExpandedAtCount == collapsedInitialLimit && optionState.canLoadMore {
displayCount = collapsedResultCount
} else {
displayCount = peers.count
}
} else {
if !optionState.canLoadMore {
displayCount = peers.count
} else {
displayCount = collapsedResultCount
}
}
}
var peerIndex = 0
inner: for peer in peers {
if peerIndex >= displayCount {
break inner
}
entries.append(.optionPeer(optionId: i, index: peerIndex, peer: EngineRenderedPeer(peer.peer), timestamp: peer.date, optionText: optionTextHeader, optionTextEntities: optionTextHeaderEntities, optionAdditionalText: optionAdditionalTextHeader, optionCount: Int32(count), optionExpanded: optionExpandedAtCount != nil, opaqueIdentifier: option.opaqueIdentifier, shimmeringAlternation: nil, isFirstInOption: peerIndex == 0))
peerIndex += 1
}
let remainingCount = count - peerIndex
if remainingCount > 0 {
entries.append(.optionExpand(optionId: i, opaqueIdentifier: option.opaqueIdentifier, text: presentationData.strings.PollResults_ShowMore(Int32(remainingCount)), enabled: true))
}
}
}
}
return entries
}
public func pollResultsController(context: AccountContext, messageId: EngineMessage.Id, message: EngineMessage, poll: TelegramMediaPoll, focusOnOptionWithOpaqueIdentifier: Data? = nil) -> ViewController {
let statePromise = ValuePromise(PollResultsControllerState(), ignoreRepeated: true)
let stateValue = Atomic(value: PollResultsControllerState())
let updateState: ((PollResultsControllerState) -> PollResultsControllerState) -> Void = { f in
statePromise.set(stateValue.modify { f($0) })
}
var pushControllerImpl: ((ViewController) -> Void)?
let actionsDisposable = DisposableSet()
let resultsContext = context.engine.messages.pollResults(messageId: messageId, poll: poll)
let arguments = PollResultsControllerArguments(context: context, message: message,
collapseOption: { optionId in
updateState { state in
var state = state
state.expandedOptions.removeValue(forKey: optionId)
return state
}
}, expandOption: { optionId in
let _ = (resultsContext.state
|> take(1)
|> deliverOnMainQueue).startStandalone(next: { [weak resultsContext] state in
if let optionState = state.options[optionId] {
updateState { state in
var state = state
state.expandedOptions[optionId] = optionState.peers.count
return state
}
if optionState.canLoadMore {
resultsContext?.loadMore(optionOpaqueIdentifier: optionId)
}
}
})
}, openPeer: { peer in
if let peer = peer.peers[peer.peerId] {
if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) {
pushControllerImpl?(controller)
}
}
}, expandSolution: {
})
let previousWasEmpty = Atomic<Bool?>(value: nil)
let signal = combineLatest(queue: .mainQueue(),
context.sharedContext.presentationData,
statePromise.get(),
resultsContext.state
)
|> map { presentationData, state, resultsState -> (ItemListControllerState, (ItemListNodeState, Any)) in
var isEmpty = false
for (_, optionState) in resultsState.options {
if !optionState.hasLoadedOnce {
isEmpty = true
break
}
}
let previousWasEmptyValue = previousWasEmpty.swap(isEmpty)
var totalVoters: Int32 = 0
if let totalVotersValue = poll.results.totalVoters {
totalVoters = totalVotersValue
}
let entries = pollResultsControllerEntries(presentationData: presentationData, message: message, poll: poll, state: state, resultsState: resultsState)
var initialScrollToItem: ListViewScrollToItem?
if let focusOnOptionWithOpaqueIdentifier = focusOnOptionWithOpaqueIdentifier, previousWasEmptyValue == nil {
var isFirstOption = true
loop: for i in 0 ..< entries.count {
switch entries[i] {
case let .optionPeer(_, _, _, _, _, _, _, _, _, opaqueIdentifier, _, _):
if opaqueIdentifier == focusOnOptionWithOpaqueIdentifier {
if !isFirstOption {
initialScrollToItem = ListViewScrollToItem(index: i, position: .top(0.0), animated: false, curve: .Default(duration: nil), directionHint: .Down)
}
break loop
}
isFirstOption = false
default:
break
}
}
}
let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .textWithSubtitle(presentationData.strings.PollResults_Title, presentationData.strings.MessagePoll_VotedCount(totalVoters)), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: false)
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, focusItemTag: nil, emptyStateItem: nil, initialScrollToItem: initialScrollToItem, crossfadeState: previousWasEmptyValue != nil && previousWasEmptyValue == true && isEmpty == false, animateChanges: false)
return (controllerState, (listState, arguments))
}
|> afterDisposed {
actionsDisposable.dispose()
}
let controller = ItemListController(context: context, state: signal)
controller.navigationPresentation = .modal
pushControllerImpl = { [weak controller] c in
controller?.push(c)
}
controller.acceptsFocusWhenInOverlay = true
return controller
}
private func formatTimeAndDate(timestamp: Int32, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat) -> (time: String, date: String?) {
var t: time_t = time_t(timestamp)
var timeinfo: tm = tm()
localtime_r(&t, &timeinfo)
let timestampNow = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970)
var now: time_t = time_t(timestampNow)
var timeinfoNow: tm = tm()
localtime_r(&now, &timeinfoNow)
let timeString = stringForShortTimestamp(hours: timeinfo.tm_hour, minutes: timeinfo.tm_min, dateTimeFormat: dateTimeFormat)
let dateString: String?
let dayDifference = timeinfo.tm_yday - timeinfoNow.tm_yday
if dayDifference == 0 {
dateString = ""
} else if dayDifference == -1 {
dateString = strings.Weekday_Yesterday.lowercased()
} else {
dateString = stringForShortDate(timestamp: timestamp, strings: strings, dateTimeFormat: dateTimeFormat, withTime: false)
}
return (time: timeString, date: dateString)
}