mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
857 lines
42 KiB
Swift
857 lines
42 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import Display
|
|
import Postbox
|
|
import TelegramCore
|
|
import AccountContext
|
|
import LocalizedPeerData
|
|
import ContextUI
|
|
import ChatListUI
|
|
import TelegramPresentationData
|
|
import SwiftSignalKit
|
|
import ChatControllerInteraction
|
|
import ChatMessageItemCommon
|
|
|
|
func chatMessageItemLayoutConstants(_ constants: (ChatMessageItemLayoutConstants, ChatMessageItemLayoutConstants), params: ListViewItemLayoutParams, presentationData: ChatPresentationData) -> ChatMessageItemLayoutConstants {
|
|
var result: ChatMessageItemLayoutConstants
|
|
if params.width > 680.0 {
|
|
result = constants.1
|
|
} else {
|
|
result = constants.0
|
|
}
|
|
result.image.defaultCornerRadius = presentationData.chatBubbleCorners.mainRadius
|
|
result.image.mergedCornerRadius = (presentationData.chatBubbleCorners.mergeBubbleCorners && result.image.defaultCornerRadius >= 10.0) ? presentationData.chatBubbleCorners.auxiliaryRadius : presentationData.chatBubbleCorners.mainRadius
|
|
let minRadius: CGFloat = 4.0
|
|
let maxRadius: CGFloat = 16.0
|
|
let radiusTransition = (presentationData.chatBubbleCorners.mainRadius - minRadius) / (maxRadius - minRadius)
|
|
let minInset: CGFloat = result.text.bubbleInsets.left
|
|
let maxInset: CGFloat = 11.0
|
|
let textInset: CGFloat = min(maxInset, ceil(maxInset * radiusTransition + minInset * (1.0 - radiusTransition)))
|
|
result.text.bubbleInsets.left = textInset
|
|
result.text.bubbleInsets.right = textInset
|
|
result.instantVideo.dimensions = params.width > 320.0 ? constants.1.instantVideo.dimensions : constants.0.instantVideo.dimensions
|
|
return result
|
|
}
|
|
|
|
enum ChatMessageItemBottomNeighbor {
|
|
case none
|
|
case merged(semi: Bool)
|
|
}
|
|
|
|
private let voiceMessageDurationFormatter: DateComponentsFormatter = {
|
|
let formatter = DateComponentsFormatter()
|
|
formatter.unitsStyle = .spellOut
|
|
formatter.allowedUnits = [.minute, .second]
|
|
return formatter
|
|
}()
|
|
|
|
private let musicDurationFormatter: DateComponentsFormatter = {
|
|
let formatter = DateComponentsFormatter()
|
|
formatter.unitsStyle = .spellOut
|
|
formatter.allowedUnits = [.hour, .minute, .second]
|
|
return formatter
|
|
}()
|
|
|
|
private let fileSizeFormatter: ByteCountFormatter = {
|
|
let formatter = ByteCountFormatter()
|
|
formatter.allowsNonnumericFormatting = true
|
|
return formatter
|
|
}()
|
|
|
|
enum ChatMessageAccessibilityCustomActionType {
|
|
case reply
|
|
case options
|
|
}
|
|
|
|
final class ChatMessageAccessibilityCustomAction: UIAccessibilityCustomAction {
|
|
let action: ChatMessageAccessibilityCustomActionType
|
|
|
|
init(name: String, target: Any?, selector: Selector, action: ChatMessageAccessibilityCustomActionType) {
|
|
self.action = action
|
|
|
|
super.init(name: name, target: target, selector: selector)
|
|
}
|
|
}
|
|
|
|
final class ChatMessageAccessibilityData {
|
|
let label: String?
|
|
let value: String?
|
|
let hint: String?
|
|
let traits: UIAccessibilityTraits
|
|
let customActions: [ChatMessageAccessibilityCustomAction]?
|
|
let singleUrl: String?
|
|
|
|
init(item: ChatMessageItem, isSelected: Bool?) {
|
|
var hint: String?
|
|
var traits: UIAccessibilityTraits = []
|
|
var singleUrl: String?
|
|
|
|
var customActions: [ChatMessageAccessibilityCustomAction] = []
|
|
|
|
let isIncoming = item.message.effectivelyIncoming(item.context.account.peerId)
|
|
var announceIncomingAuthors = false
|
|
if let peer = item.message.peers[item.message.id.peerId] {
|
|
if peer is TelegramGroup {
|
|
announceIncomingAuthors = true
|
|
} else if let channel = peer as? TelegramChannel, case .group = channel.info {
|
|
announceIncomingAuthors = true
|
|
}
|
|
}
|
|
|
|
let dataForMessage: (Message, Bool) -> (String, String) = { message, isReply -> (String, String) in
|
|
var label: String = ""
|
|
var value: String = ""
|
|
|
|
if let chatPeer = message.peers[item.message.id.peerId] {
|
|
let authorName = message.author.flatMap(EnginePeer.init)?.displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder)
|
|
|
|
let (_, _, messageText, _, _) = chatListItemStrings(strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, dateTimeFormat: item.presentationData.dateTimeFormat, contentSettings: item.context.currentContentSettings.with { $0 }, messages: [EngineMessage(message)], chatPeer: EngineRenderedPeer(peer: EnginePeer(chatPeer)), accountPeerId: item.context.account.peerId)
|
|
|
|
var text = messageText
|
|
|
|
loop: for media in message.media {
|
|
if let _ = media as? TelegramMediaImage {
|
|
traits.insert(.image)
|
|
if isIncoming {
|
|
if announceIncomingAuthors, let authorName = authorName {
|
|
label = item.presentationData.strings.VoiceOver_Chat_PhotoFrom(authorName).string
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_Photo
|
|
}
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_YourPhoto
|
|
}
|
|
text = ""
|
|
if !message.text.isEmpty {
|
|
text.append("\n")
|
|
|
|
text.append(item.presentationData.strings.VoiceOver_Chat_Caption(message.text).string)
|
|
}
|
|
} else if let file = media as? TelegramMediaFile {
|
|
var isSpecialFile = false
|
|
|
|
let isVideo = file.isInstantVideo
|
|
|
|
for attribute in file.attributes {
|
|
switch attribute {
|
|
case let .Sticker(displayText, _, _):
|
|
isSpecialFile = true
|
|
text = displayText
|
|
if file.mimeType == "application/x-tgsticker" {
|
|
if isIncoming {
|
|
if announceIncomingAuthors, let authorName = authorName {
|
|
label = item.presentationData.strings.VoiceOver_Chat_AnimatedStickerFrom(authorName).string
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_AnimatedSticker
|
|
}
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_YourAnimatedSticker
|
|
}
|
|
} else {
|
|
if isIncoming {
|
|
if announceIncomingAuthors, let authorName = authorName {
|
|
label = item.presentationData.strings.VoiceOver_Chat_StickerFrom(authorName).string
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_Sticker
|
|
}
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_YourSticker
|
|
}
|
|
}
|
|
case let .Audio(isVoice, duration, title, performer, _):
|
|
if isVideo {
|
|
continue
|
|
}
|
|
isSpecialFile = true
|
|
if isSelected == nil {
|
|
hint = item.presentationData.strings.VoiceOver_Chat_PlayHint
|
|
}
|
|
traits.insert(.startsMediaSession)
|
|
if isVoice {
|
|
let durationString = voiceMessageDurationFormatter.string(from: Double(duration)) ?? ""
|
|
if isIncoming {
|
|
if announceIncomingAuthors, let authorName = authorName {
|
|
label = item.presentationData.strings.VoiceOver_Chat_VoiceMessageFrom(authorName).string
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_VoiceMessage
|
|
}
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_YourVoiceMessage
|
|
}
|
|
text = item.presentationData.strings.VoiceOver_Chat_Duration(durationString).string
|
|
} else {
|
|
let durationString = musicDurationFormatter.string(from: Double(duration)) ?? ""
|
|
if isIncoming {
|
|
if announceIncomingAuthors, let authorName = authorName {
|
|
label = item.presentationData.strings.VoiceOver_Chat_MusicFrom(authorName).string
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_Music
|
|
}
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_YourMusic
|
|
}
|
|
let performer = performer ?? "Unknown"
|
|
let title = title ?? "Unknown"
|
|
|
|
text = item.presentationData.strings.VoiceOver_Chat_MusicTitle(title, performer).string
|
|
text.append(item.presentationData.strings.VoiceOver_Chat_Duration(durationString).string)
|
|
}
|
|
case let .Video(duration, _, flags, _):
|
|
isSpecialFile = true
|
|
if isSelected == nil {
|
|
hint = item.presentationData.strings.VoiceOver_Chat_PlayHint
|
|
}
|
|
traits.insert(.startsMediaSession)
|
|
let durationString = voiceMessageDurationFormatter.string(from: Double(duration)) ?? ""
|
|
if flags.contains(.instantRoundVideo) {
|
|
if isIncoming {
|
|
if announceIncomingAuthors, let authorName = authorName {
|
|
label = item.presentationData.strings.VoiceOver_Chat_VideoMessageFrom(authorName).string
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_VideoMessage
|
|
}
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_YourVideoMessage
|
|
}
|
|
} else {
|
|
if isIncoming {
|
|
if announceIncomingAuthors, let authorName = authorName {
|
|
label = item.presentationData.strings.VoiceOver_Chat_VideoFrom(authorName).string
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_Video
|
|
}
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_YourVideo
|
|
}
|
|
}
|
|
text = item.presentationData.strings.VoiceOver_Chat_Duration(durationString).string
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
if !isSpecialFile {
|
|
if isSelected == nil {
|
|
hint = item.presentationData.strings.VoiceOver_Chat_OpenHint
|
|
}
|
|
let sizeString = fileSizeFormatter.string(fromByteCount: Int64(file.size ?? 0))
|
|
if isIncoming {
|
|
if announceIncomingAuthors, let authorName = authorName {
|
|
label = item.presentationData.strings.VoiceOver_Chat_FileFrom(authorName).string
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_File
|
|
}
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_YourFile
|
|
}
|
|
text = "\(file.fileName ?? ""). "
|
|
text.append(item.presentationData.strings.VoiceOver_Chat_Size(sizeString).string)
|
|
}
|
|
if !message.text.isEmpty {
|
|
text.append("\n")
|
|
text.append(item.presentationData.strings.VoiceOver_Chat_Caption(message.text).string)
|
|
}
|
|
break loop
|
|
} else if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content {
|
|
var contentText = item.presentationData.strings.VoiceOver_Chat_PagePreview + ". "
|
|
if let title = content.title, !title.isEmpty {
|
|
contentText.append(item.presentationData.strings.VoiceOver_Chat_Title(title).string)
|
|
contentText.append(". ")
|
|
}
|
|
if let text = content.text, !text.isEmpty {
|
|
contentText.append(text)
|
|
}
|
|
text = "\(message.text)\n\(contentText)"
|
|
} else if let contact = media as? TelegramMediaContact {
|
|
if isIncoming {
|
|
if announceIncomingAuthors, let authorName = authorName {
|
|
label = item.presentationData.strings.VoiceOver_Chat_ContactFrom(authorName).string
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_Contact
|
|
}
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_YourContact
|
|
}
|
|
var displayName = ""
|
|
if !contact.firstName.isEmpty {
|
|
displayName.append(contact.firstName)
|
|
}
|
|
if !contact.lastName.isEmpty {
|
|
if !displayName.isEmpty {
|
|
displayName.append(" ")
|
|
}
|
|
displayName.append(contact.lastName)
|
|
}
|
|
var phoneNumbersString = ""
|
|
var phoneNumberCount = 0
|
|
var emailAddressesString = ""
|
|
var emailAddressCount = 0
|
|
var organizationString = ""
|
|
if let vCard = contact.vCardData, let vCardData = vCard.data(using: .utf8), let contactData = DeviceContactExtendedData(vcard: vCardData) {
|
|
if displayName.isEmpty && !contactData.organization.isEmpty {
|
|
displayName = contactData.organization
|
|
}
|
|
if !contactData.basicData.phoneNumbers.isEmpty {
|
|
for phone in contactData.basicData.phoneNumbers {
|
|
if !phoneNumbersString.isEmpty {
|
|
phoneNumbersString.append(", ")
|
|
}
|
|
for c in phone.value {
|
|
phoneNumbersString.append(c)
|
|
phoneNumbersString.append(" ")
|
|
}
|
|
phoneNumberCount += 1
|
|
}
|
|
} else {
|
|
for c in contact.phoneNumber {
|
|
phoneNumbersString.append(c)
|
|
phoneNumbersString.append(" ")
|
|
}
|
|
phoneNumberCount += 1
|
|
}
|
|
|
|
for email in contactData.emailAddresses {
|
|
if !emailAddressesString.isEmpty {
|
|
emailAddressesString.append(", ")
|
|
}
|
|
emailAddressesString.append("\(email.value)")
|
|
emailAddressCount += 1
|
|
}
|
|
if !contactData.organization.isEmpty && displayName != contactData.organization {
|
|
organizationString = contactData.organization
|
|
}
|
|
} else {
|
|
phoneNumbersString.append("\(contact.phoneNumber)")
|
|
}
|
|
text = "\(displayName)."
|
|
if !phoneNumbersString.isEmpty {
|
|
if phoneNumberCount > 1 {
|
|
text.append(item.presentationData.strings.VoiceOver_Chat_ContactPhoneNumberCount(Int32(phoneNumberCount)))
|
|
text.append(": ")
|
|
} else {
|
|
text.append(item.presentationData.strings.VoiceOver_Chat_ContactPhoneNumber)
|
|
}
|
|
text.append("\(phoneNumbersString). ")
|
|
}
|
|
if !emailAddressesString.isEmpty {
|
|
if emailAddressCount > 1 {
|
|
text.append(item.presentationData.strings.VoiceOver_Chat_ContactEmailCount(Int32(emailAddressCount)))
|
|
text.append(": ")
|
|
} else {
|
|
text.append(item.presentationData.strings.VoiceOver_Chat_ContactEmail)
|
|
text.append(": ")
|
|
}
|
|
text.append("\(emailAddressesString). ")
|
|
}
|
|
if !organizationString.isEmpty {
|
|
text.append(item.presentationData.strings.VoiceOver_Chat_ContactOrganization(organizationString).string)
|
|
text.append(".")
|
|
}
|
|
} else if let poll = media as? TelegramMediaPoll {
|
|
if isIncoming {
|
|
if announceIncomingAuthors, let authorName = authorName {
|
|
label = item.presentationData.strings.VoiceOver_Chat_AnonymousPollFrom(authorName).string
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_AnonymousPoll
|
|
}
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_YourAnonymousPoll
|
|
}
|
|
|
|
var optionVoterCount: [Int: Int32] = [:]
|
|
var maxOptionVoterCount: Int32 = 0
|
|
var totalVoterCount: Int32 = 0
|
|
let voters: [TelegramMediaPollOptionVoters]?
|
|
if poll.isClosed {
|
|
voters = poll.results.voters ?? []
|
|
} else {
|
|
voters = poll.results.voters
|
|
}
|
|
var selectedOptionId: Data?
|
|
if let voters = voters, let totalVoters = poll.results.totalVoters {
|
|
var didVote = false
|
|
for voter in voters {
|
|
if voter.selected {
|
|
didVote = true
|
|
selectedOptionId = voter.opaqueIdentifier
|
|
}
|
|
}
|
|
totalVoterCount = totalVoters
|
|
if didVote || poll.isClosed {
|
|
for i in 0 ..< poll.options.count {
|
|
inner: for optionVoters in voters {
|
|
if optionVoters.opaqueIdentifier == poll.options[i].opaqueIdentifier {
|
|
optionVoterCount[i] = optionVoters.count
|
|
maxOptionVoterCount = max(maxOptionVoterCount, optionVoters.count)
|
|
break inner
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var optionVoterCounts: [Int]
|
|
if totalVoterCount != 0 {
|
|
optionVoterCounts = countNicePercent(votes: (0 ..< poll.options.count).map({ Int(optionVoterCount[$0] ?? 0) }), total: Int(totalVoterCount))
|
|
} else {
|
|
optionVoterCounts = Array(repeating: 0, count: poll.options.count)
|
|
}
|
|
|
|
text = item.presentationData.strings.VoiceOver_Chat_Title(poll.text).string
|
|
text.append(". ")
|
|
|
|
text.append(item.presentationData.strings.VoiceOver_Chat_PollOptionCount(Int32(poll.options.count)))
|
|
text.append(": ")
|
|
var optionsText = ""
|
|
for i in 0 ..< poll.options.count {
|
|
let option = poll.options[i]
|
|
|
|
if !optionsText.isEmpty {
|
|
optionsText.append(", ")
|
|
}
|
|
optionsText.append(option.text)
|
|
if let selectedOptionId = selectedOptionId, selectedOptionId == option.opaqueIdentifier {
|
|
optionsText.append(", ")
|
|
optionsText.append(item.presentationData.strings.VoiceOver_Chat_OptionSelected)
|
|
}
|
|
|
|
if let _ = optionVoterCount[i] {
|
|
if maxOptionVoterCount != 0 && totalVoterCount != 0 {
|
|
optionsText.append(", \(optionVoterCounts[i])%")
|
|
}
|
|
}
|
|
}
|
|
text.append("\(optionsText). ")
|
|
if totalVoterCount != 0 {
|
|
text.append(item.presentationData.strings.VoiceOver_Chat_PollVotes(Int32(totalVoterCount)))
|
|
} else {
|
|
text.append(item.presentationData.strings.VoiceOver_Chat_PollNoVotes)
|
|
}
|
|
if poll.isClosed {
|
|
text.append(item.presentationData.strings.VoiceOver_Chat_PollFinalResults)
|
|
}
|
|
}
|
|
}
|
|
|
|
var result = ""
|
|
|
|
if let isSelected = isSelected {
|
|
if isSelected {
|
|
result += item.presentationData.strings.VoiceOver_Chat_Selected
|
|
result += "\n"
|
|
}
|
|
traits.insert(.startsMediaSession)
|
|
}
|
|
|
|
result += "\(text)"
|
|
|
|
let dateString = DateFormatter.localizedString(from: Date(timeIntervalSince1970: Double(message.timestamp)), dateStyle: .medium, timeStyle: .short)
|
|
|
|
result += "\n\(dateString)"
|
|
if !isIncoming && !isReply {
|
|
result += "\n"
|
|
if item.sending {
|
|
result += item.presentationData.strings.VoiceOver_Chat_Sending
|
|
} else if item.failed {
|
|
result += item.presentationData.strings.VoiceOver_Chat_Failed
|
|
} else {
|
|
if item.read {
|
|
if announceIncomingAuthors {
|
|
result += item.presentationData.strings.VoiceOver_Chat_SeenByRecipients
|
|
} else {
|
|
result += item.presentationData.strings.VoiceOver_Chat_SeenByRecipient
|
|
}
|
|
}
|
|
for attribute in message.attributes {
|
|
if let attribute = attribute as? ConsumableContentMessageAttribute {
|
|
if !attribute.consumed {
|
|
if announceIncomingAuthors {
|
|
result += item.presentationData.strings.VoiceOver_Chat_NotPlayedByRecipients
|
|
} else {
|
|
result += item.presentationData.strings.VoiceOver_Chat_NotPlayedByRecipient
|
|
}
|
|
} else {
|
|
if announceIncomingAuthors {
|
|
result += item.presentationData.strings.VoiceOver_Chat_PlayedByRecipients
|
|
} else {
|
|
result += item.presentationData.strings.VoiceOver_Chat_PlayedByRecipient
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
value = result
|
|
} else {
|
|
value = ""
|
|
}
|
|
|
|
if label.isEmpty {
|
|
if let author = message.author {
|
|
if isIncoming {
|
|
label = EnginePeer(author).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder)
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_YourMessage
|
|
}
|
|
} else {
|
|
label = item.presentationData.strings.VoiceOver_Chat_Message
|
|
}
|
|
}
|
|
|
|
return (label, value)
|
|
}
|
|
|
|
var (label, value) = dataForMessage(item.message, false)
|
|
var replyValue: String?
|
|
|
|
for attribute in item.message.attributes {
|
|
if let attribute = attribute as? TextEntitiesMessageAttribute {
|
|
var hasUrls = false
|
|
loop: for entity in attribute.entities {
|
|
switch entity.type {
|
|
case .Url:
|
|
if hasUrls {
|
|
singleUrl = nil
|
|
break loop
|
|
} else {
|
|
if let range = Range<String.Index>(NSRange(location: entity.range.lowerBound, length: entity.range.count), in: item.message.text) {
|
|
singleUrl = String(item.message.text[range])
|
|
hasUrls = true
|
|
}
|
|
}
|
|
case let .TextUrl(url):
|
|
if hasUrls {
|
|
singleUrl = nil
|
|
break loop
|
|
} else {
|
|
singleUrl = url
|
|
hasUrls = true
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
} else if let attribute = attribute as? ReplyMessageAttribute, let replyMessage = item.message.associatedMessages[attribute.messageId] {
|
|
var replyLabel: String
|
|
if replyMessage.flags.contains(.Incoming) {
|
|
if let author = replyMessage.author {
|
|
replyLabel = item.presentationData.strings.VoiceOver_Chat_ReplyFrom(EnginePeer(author).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder)).string
|
|
} else {
|
|
replyLabel = item.presentationData.strings.VoiceOver_Chat_Reply
|
|
}
|
|
} else {
|
|
replyLabel = item.presentationData.strings.VoiceOver_Chat_ReplyToYourMessage
|
|
}
|
|
|
|
let (_, replyMessageValue) = dataForMessage(replyMessage, true)
|
|
replyValue = replyMessageValue
|
|
|
|
label = "\(replyLabel) . \(label)"
|
|
}
|
|
}
|
|
|
|
if hint == nil && singleUrl != nil {
|
|
hint = item.presentationData.strings.VoiceOver_Chat_OpenLinkHint
|
|
}
|
|
|
|
if let forwardInfo = item.message.forwardInfo {
|
|
let forwardLabel: String
|
|
if let author = forwardInfo.author, author.id == item.context.account.peerId {
|
|
forwardLabel = item.presentationData.strings.VoiceOver_Chat_ForwardedFromYou
|
|
} else {
|
|
let peerString: String
|
|
if let peer = forwardInfo.author {
|
|
if let authorName = forwardInfo.authorSignature {
|
|
peerString = "\(EnginePeer(peer).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder)) (\(authorName))"
|
|
} else {
|
|
peerString = EnginePeer(peer).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder)
|
|
}
|
|
} else if let authorName = forwardInfo.authorSignature {
|
|
peerString = authorName
|
|
} else {
|
|
peerString = ""
|
|
}
|
|
forwardLabel = item.presentationData.strings.VoiceOver_Chat_ForwardedFrom(peerString).string
|
|
}
|
|
label = "\(forwardLabel). \(label)"
|
|
}
|
|
|
|
if isSelected == nil {
|
|
var canReply = item.controllerInteraction.canSetupReply(item.message) == .reply
|
|
for media in item.content.firstMessage.media {
|
|
if let _ = media as? TelegramMediaExpiredContent {
|
|
canReply = false
|
|
}
|
|
else if let media = media as? TelegramMediaAction {
|
|
if case .phoneCall = media.action {
|
|
} else {
|
|
canReply = false
|
|
}
|
|
}
|
|
}
|
|
|
|
if canReply {
|
|
customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.VoiceOver_MessageContextReply, target: nil, selector: #selector(self.noop), action: .reply))
|
|
}
|
|
customActions.append(ChatMessageAccessibilityCustomAction(name: item.presentationData.strings.VoiceOver_MessageContextOpenMessageMenu, target: nil, selector: #selector(self.noop), action: .options))
|
|
}
|
|
|
|
if let replyValue {
|
|
value = "\(value). \(item.presentationData.strings.VoiceOver_Chat_ReplyingToMessage(replyValue).string)"
|
|
}
|
|
|
|
self.label = label
|
|
self.value = value
|
|
self.hint = hint
|
|
self.traits = traits
|
|
self.customActions = customActions.isEmpty ? nil : customActions
|
|
self.singleUrl = singleUrl
|
|
}
|
|
|
|
@objc private func noop() {
|
|
}
|
|
}
|
|
|
|
public class ChatMessageItemView: ListViewItemNode, ChatMessageItemNodeProtocol {
|
|
let layoutConstants = (ChatMessageItemLayoutConstants.compact, ChatMessageItemLayoutConstants.regular)
|
|
|
|
var item: ChatMessageItem?
|
|
var accessibilityData: ChatMessageAccessibilityData?
|
|
var safeInsets = UIEdgeInsets()
|
|
|
|
var awaitingAppliedReaction: (MessageReaction.Reaction?, () -> Void)?
|
|
|
|
public required convenience init() {
|
|
self.init(layerBacked: false)
|
|
}
|
|
|
|
public init(layerBacked: Bool) {
|
|
super.init(layerBacked: layerBacked, dynamicBounce: true, rotated: true)
|
|
self.transform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0)
|
|
}
|
|
|
|
required public init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override public func reuse() {
|
|
super.reuse()
|
|
|
|
self.item = nil
|
|
self.frame = CGRect()
|
|
}
|
|
|
|
func setupItem(_ item: ChatMessageItem, synchronousLoad: Bool) {
|
|
self.item = item
|
|
}
|
|
|
|
func updateAccessibilityData(_ accessibilityData: ChatMessageAccessibilityData) {
|
|
self.accessibilityData = accessibilityData
|
|
}
|
|
|
|
override public func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) {
|
|
if let item = item as? ChatMessageItem {
|
|
let doLayout = self.asyncLayout()
|
|
let merged = item.mergedWithItems(top: previousItem, bottom: nextItem)
|
|
let (layout, apply) = doLayout(item, params, merged.top, merged.bottom, merged.dateAtBottom)
|
|
self.contentSize = layout.contentSize
|
|
self.insets = layout.insets
|
|
apply(.None, ListViewItemApply(isOnScreen: false), false)
|
|
}
|
|
}
|
|
|
|
func cancelInsertionAnimations() {
|
|
}
|
|
|
|
override public func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) {
|
|
if short {
|
|
//self.layer.animateBoundsOriginYAdditive(from: -self.bounds.size.height, to: 0.0, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring)
|
|
} else {
|
|
self.transitionOffset = -self.bounds.size.height * 1.6
|
|
self.addTransitionOffsetAnimation(0.0, duration: duration, beginAt: currentTimestamp)
|
|
}
|
|
}
|
|
|
|
func asyncLayout() -> (_ item: ChatMessageItem, _ params: ListViewItemLayoutParams, _ mergedTop: ChatMessageMerge, _ mergedBottom: ChatMessageMerge, _ dateHeaderAtBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation, ListViewItemApply, Bool) -> Void) {
|
|
return { _, _, _, _, _ in
|
|
return (ListViewItemNodeLayout(contentSize: CGSize(width: 32.0, height: 32.0), insets: UIEdgeInsets()), { _, _, _ in
|
|
|
|
})
|
|
}
|
|
}
|
|
|
|
func transitionNode(id: MessageId, media: Media, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
|
|
return nil
|
|
}
|
|
|
|
func getMessageContextSourceNode(stableId: UInt32?) -> ContextExtractedContentContainingNode? {
|
|
return nil
|
|
}
|
|
|
|
func updateHiddenMedia() {
|
|
}
|
|
|
|
func updateSelectionState(animated: Bool) {
|
|
}
|
|
|
|
func updateSearchTextHighlightState() {
|
|
}
|
|
|
|
func updateHighlightedState(animated: Bool) {
|
|
var isHighlightedInOverlay = false
|
|
if let item = self.item, let contextHighlightedState = item.controllerInteraction.contextHighlightedState {
|
|
switch item.content {
|
|
case let .message(message, _, _, _, _):
|
|
if contextHighlightedState.messageStableId == message.stableId {
|
|
isHighlightedInOverlay = true
|
|
}
|
|
case let .group(messages):
|
|
for (message, _, _, _, _) in messages {
|
|
if contextHighlightedState.messageStableId == message.stableId {
|
|
isHighlightedInOverlay = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.isHighlightedInOverlay = isHighlightedInOverlay
|
|
}
|
|
|
|
func updateAutomaticMediaDownloadSettings() {
|
|
}
|
|
|
|
func updateStickerSettings(forceStopAnimations: Bool) {
|
|
}
|
|
|
|
func playMediaWithSound() -> ((Double?) -> Void, Bool, Bool, Bool, ASDisplayNode?)? {
|
|
return nil
|
|
}
|
|
|
|
override public func headers() -> [ListViewItemHeader]? {
|
|
if let item = self.item {
|
|
return item.headers
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func performMessageButtonAction(button: ReplyMarkupButton) {
|
|
if let item = self.item {
|
|
switch button.action {
|
|
case .text:
|
|
item.controllerInteraction.sendMessage(button.title)
|
|
case let .url(url):
|
|
var concealed = true
|
|
if url.hasPrefix("tg://") {
|
|
concealed = false
|
|
}
|
|
item.controllerInteraction.openUrl(url, concealed, nil, nil)
|
|
case .requestMap:
|
|
item.controllerInteraction.shareCurrentLocation()
|
|
case .requestPhone:
|
|
item.controllerInteraction.shareAccountContact()
|
|
case .openWebApp:
|
|
item.controllerInteraction.requestMessageActionCallback(item.message.id, nil, true, false)
|
|
case let .callback(requiresPassword, data):
|
|
item.controllerInteraction.requestMessageActionCallback(item.message.id, data, false, requiresPassword)
|
|
case let .switchInline(samePeer, query, peerTypes):
|
|
var botPeer: Peer?
|
|
|
|
var found = false
|
|
for attribute in item.message.attributes {
|
|
if let attribute = attribute as? InlineBotMessageAttribute {
|
|
if let peerId = attribute.peerId {
|
|
botPeer = item.message.peers[peerId]
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
botPeer = item.message.author
|
|
}
|
|
|
|
var peerId: PeerId?
|
|
if samePeer {
|
|
peerId = item.message.id.peerId
|
|
}
|
|
if let botPeer = botPeer, let addressName = botPeer.addressName {
|
|
item.controllerInteraction.activateSwitchInline(peerId, "@\(addressName) \(query)", peerTypes)
|
|
}
|
|
case .payment:
|
|
item.controllerInteraction.openCheckoutOrReceipt(item.message.id)
|
|
case let .urlAuth(url, buttonId):
|
|
item.controllerInteraction.requestMessageActionUrlAuth(url, .message(id: item.message.id, buttonId: buttonId))
|
|
case .setupPoll:
|
|
break
|
|
case let .openUserProfile(peerId):
|
|
let _ = (item.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
|
|
|> deliverOnMainQueue).startStandalone(next: { peer in
|
|
if let peer = peer {
|
|
item.controllerInteraction.openPeer(peer, .info, nil, .default)
|
|
}
|
|
})
|
|
case let .openWebView(url, simple):
|
|
item.controllerInteraction.openWebView(button.title, url, simple, .generic)
|
|
case .requestPeer:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func presentMessageButtonContextMenu(button: ReplyMarkupButton) {
|
|
if let item = self.item {
|
|
switch button.action {
|
|
case let .url(url):
|
|
item.controllerInteraction.longTap(.url(url), item.message)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func openMessageContextMenu() {
|
|
}
|
|
|
|
public func targetReactionView(value: MessageReaction.Reaction) -> UIView? {
|
|
return nil
|
|
}
|
|
|
|
public func targetForStoryTransition(id: StoryId) -> UIView? {
|
|
return nil
|
|
}
|
|
|
|
func getStatusNode() -> ASDisplayNode? {
|
|
return nil
|
|
}
|
|
|
|
private var attachedAvatarNodeOffset: CGFloat = 0.0
|
|
|
|
override public func attachedHeaderNodesUpdated() {
|
|
if !self.attachedAvatarNodeOffset.isZero {
|
|
self.updateAttachedAvatarNodeOffset(offset: self.attachedAvatarNodeOffset, transition: .immediate)
|
|
} else {
|
|
for headerNode in self.attachedHeaderNodes {
|
|
if let headerNode = headerNode as? ChatMessageAvatarHeaderNode {
|
|
headerNode.updateSelectionState(animated: false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateAttachedAvatarNodeOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) {
|
|
self.attachedAvatarNodeOffset = offset
|
|
for headerNode in self.attachedHeaderNodes {
|
|
if let headerNode = headerNode as? ChatMessageAvatarHeaderNode {
|
|
transition.updateSublayerTransformOffset(layer: headerNode.layer, offset: CGPoint(x: offset, y: 0.0))
|
|
}
|
|
}
|
|
}
|
|
|
|
func unreadMessageRangeUpdated() {
|
|
}
|
|
|
|
public func contentFrame() -> CGRect {
|
|
return self.bounds
|
|
}
|
|
}
|