Support view list

This commit is contained in:
Ali 2021-09-03 22:10:02 +04:00
parent 8a6f5dd66e
commit 6404f54b80
8 changed files with 586 additions and 76 deletions

View File

@ -6793,3 +6793,6 @@ Sorry for the inconvenience.";
"Gif.Emotion.Party" = "Party";
"Conversation.ForwardFrom" = "From: %@";
"Conversation.ContextMenuSeen_1" = "1 Seen";
"Conversation.ContextMenuSeen_any" = "%@ Seen";

View File

@ -11,14 +11,17 @@ import AudioBlob
public final class AnimatedAvatarSetContext {
public final class Content {
fileprivate final class Item {
fileprivate struct Key: Hashable {
var peerId: EnginePeer.Id
fileprivate enum Key: Hashable {
case peer(EnginePeer.Id)
case placeholder(Int)
}
fileprivate let peer: EnginePeer
fileprivate let peer: EnginePeer?
fileprivate let placeholderColor: UIColor
fileprivate init(peer: EnginePeer) {
fileprivate init(peer: EnginePeer?, placeholderColor: UIColor) {
self.peer = peer
self.placeholderColor = placeholderColor
}
}
@ -46,7 +49,15 @@ public final class AnimatedAvatarSetContext {
public func update(peers: [EnginePeer], animated: Bool) -> Content {
var items: [(Content.Item.Key, Content.Item)] = []
for peer in peers {
items.append((Content.Item.Key(peerId: peer.id), Content.Item(peer: peer)))
items.append((.peer(peer.id), Content.Item(peer: peer, placeholderColor: .white)))
}
return Content(items: items)
}
public func updatePlaceholder(color: UIColor, count: Int, animated: Bool) -> Content {
var items: [(Content.Item.Key, Content.Item)] = []
for i in 0 ..< count {
items.append((.placeholder(i), Content.Item(peer: nil, placeholderColor: color)))
}
return Content(items: items)
}
@ -59,10 +70,16 @@ private final class ContentNode: ASDisplayNode {
private var audioLevelBlobOverlay: UIImageView?
private let unclippedNode: ASImageNode
private let clippedNode: ASImageNode
private var size: CGSize
private var spacing: CGFloat
private var disposable: Disposable?
init(context: AccountContext, peer: EnginePeer, synchronousLoad: Bool) {
init(context: AccountContext, peer: EnginePeer?, placeholderColor: UIColor, synchronousLoad: Bool, size: CGSize, spacing: CGFloat) {
self.size = size
self.spacing = spacing
self.unclippedNode = ASImageNode()
self.clippedNode = ASImageNode()
@ -70,38 +87,47 @@ private final class ContentNode: ASDisplayNode {
self.addSubnode(self.unclippedNode)
self.addSubnode(self.clippedNode)
if let representation = peer.smallProfileImage, let signal = peerAvatarImage(account: context.account, peerReference: PeerReference(peer._asPeer()), authorOfMessage: nil, representation: representation, displayDimensions: CGSize(width: 30.0, height: 30.0), synchronousLoad: synchronousLoad) {
let image = generateImage(CGSize(width: 30.0, height: 30.0), rotatedContext: { size, context in
if let peer = peer {
if let representation = peer.smallProfileImage, let signal = peerAvatarImage(account: context.account, peerReference: PeerReference(peer._asPeer()), authorOfMessage: nil, representation: representation, displayDimensions: size, synchronousLoad: synchronousLoad) {
let image = generateImage(size, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(UIColor.lightGray.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
})!
self.updateImage(image: image, size: size, spacing: spacing)
let disposable = (signal
|> deliverOnMainQueue).start(next: { [weak self] imageVersions in
guard let strongSelf = self else {
return
}
let image = imageVersions?.0
if let image = image {
strongSelf.updateImage(image: image, size: size, spacing: spacing)
}
})
self.disposable = disposable
} else {
let image = generateImage(size, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
drawPeerAvatarLetters(context: context, size: size, font: avatarFont, letters: peer.displayLetters, peerId: peer.id)
})!
self.updateImage(image: image, size: size, spacing: spacing)
}
} else {
let image = generateImage(size, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(UIColor.lightGray.cgColor)
context.setFillColor(placeholderColor.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
})!
self.updateImage(image: image)
let disposable = (signal
|> deliverOnMainQueue).start(next: { [weak self] imageVersions in
guard let strongSelf = self else {
return
}
let image = imageVersions?.0
if let image = image {
strongSelf.updateImage(image: image)
}
})
self.disposable = disposable
} else {
let image = generateImage(CGSize(width: 30.0, height: 30.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
drawPeerAvatarLetters(context: context, size: size, font: avatarFont, letters: peer.displayLetters, peerId: peer.id)
})!
self.updateImage(image: image)
self.updateImage(image: image, size: size, spacing: spacing)
}
}
private func updateImage(image: UIImage) {
private func updateImage(image: UIImage, size: CGSize, spacing: CGFloat) {
self.unclippedNode.image = image
self.clippedNode.image = generateImage(CGSize(width: 30.0, height: 30.0), rotatedContext: { size, context in
self.clippedNode.image = generateImage(size, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.translateBy(x: size.width / 2.0, y: size.height / 2.0)
context.scaleBy(x: 1.0, y: -1.0)
@ -113,7 +139,7 @@ private final class ContentNode: ASDisplayNode {
context.setBlendMode(.copy)
context.setFillColor(UIColor.clear.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size).insetBy(dx: -1.5, dy: -1.5).offsetBy(dx: -20.0, dy: 0.0))
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size).insetBy(dx: -1.5, dy: -1.5).offsetBy(dx: spacing - size.width, dy: 0.0))
})
}
@ -192,9 +218,16 @@ public final class AnimatedAvatarSetNode: ASDisplayNode {
super.init()
}
public func update(context: AccountContext, content: AnimatedAvatarSetContext.Content, itemSize: CGSize = CGSize(width: 30.0, height: 30.0), animated: Bool, synchronousLoad: Bool) -> CGSize {
public func update(context: AccountContext, content: AnimatedAvatarSetContext.Content, itemSize: CGSize = CGSize(width: 30.0, height: 30.0), customSpacing: CGFloat? = nil, animated: Bool, synchronousLoad: Bool) -> CGSize {
var contentWidth: CGFloat = 0.0
let contentHeight: CGFloat = itemSize.height
let spacing: CGFloat
if let customSpacing = customSpacing {
spacing = customSpacing
} else {
spacing = 10.0
}
let transition: ContainedViewLayoutTransition
if animated {
@ -218,7 +251,7 @@ public final class AnimatedAvatarSetNode: ASDisplayNode {
itemNode.updateLayout(size: itemSize, isClipped: index != 0, animated: animated)
transition.updateFrame(node: itemNode, frame: itemFrame)
} else {
itemNode = ContentNode(context: context, peer: item.peer, synchronousLoad: synchronousLoad)
itemNode = ContentNode(context: context, peer: item.peer, placeholderColor: item.placeholderColor, synchronousLoad: synchronousLoad, size: itemSize, spacing: spacing)
self.addSubnode(itemNode)
self.contentNodes[key] = itemNode
itemNode.updateLayout(size: itemSize, isClipped: index != 0, animated: false)
@ -229,7 +262,7 @@ public final class AnimatedAvatarSetNode: ASDisplayNode {
}
}
itemNode.zPosition = CGFloat(100 - i)
contentWidth += itemSize.width - 10.0
contentWidth += itemSize.width - spacing
index += 1
}
var removeKeys: [AnimatedAvatarSetContext.Content.Item.Key] = []
@ -253,7 +286,7 @@ public final class AnimatedAvatarSetNode: ASDisplayNode {
public func updateAudioLevels(color: UIColor, backgroundColor: UIColor, levels: [EnginePeer.Id: Float]) {
for (key, itemNode) in self.contentNodes {
if let value = levels[key.peerId] {
if case let .peer(peerId) = key, let value = levels[peerId] {
itemNode.updateAudioLevel(color: color, backgroundColor: backgroundColor, value: value)
} else {
itemNode.updateAudioLevel(color: color, backgroundColor: backgroundColor, value: 0.0)

View File

@ -13,6 +13,7 @@ enum ContextActionSibling {
public protocol ContextActionNodeProtocol: ASDisplayNode {
func setIsHighlighted(_ value: Bool)
func performAction()
var isActionEnabled: Bool { get }
}
final class ContextActionNode: ASDisplayNode, ContextActionNodeProtocol {
@ -32,6 +33,10 @@ final class ContextActionNode: ASDisplayNode, ContextActionNodeProtocol {
private var iconDisposable: Disposable?
private var pointerInteraction: PointerInteraction?
var isActionEnabled: Bool {
return true
}
init(presentationData: PresentationData, action: ContextMenuActionItem, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) {
self.action = action

View File

@ -139,7 +139,10 @@ private final class InnerActionsContainerNode: ASDisplayNode {
guard let strongSelf = self else {
return
}
let actionNode = strongSelf.actionNode(at: point)
var actionNode = strongSelf.actionNode(at: point)
if let actionNodeValue = actionNode, !actionNodeValue.isActionEnabled {
actionNode = nil
}
if actionNode !== strongSelf.currentHighlightedActionNode {
if actionNode != nil, moved {
strongSelf.feedbackTap()

View File

@ -368,7 +368,11 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi
}
if strongSelf.didMoveFromInitialGesturePoint {
let actionPoint = strongSelf.view.convert(localPoint, to: strongSelf.actionsContainerNode.view)
let actionNode = strongSelf.actionsContainerNode.actionNode(at: actionPoint)
var actionNode = strongSelf.actionsContainerNode.actionNode(at: actionPoint)
if let actionNodeValue = actionNode, !actionNodeValue.isActionEnabled {
actionNode = nil
}
if strongSelf.highlightedActionNode !== actionNode {
strongSelf.highlightedActionNode?.setIsHighlighted(false)
strongSelf.highlightedActionNode = actionNode

View File

@ -0,0 +1,71 @@
import Postbox
import SwiftSignalKit
import TelegramApi
public final class MessageReadStats {
public let peers: [EnginePeer]
public init(peers: [EnginePeer]) {
self.peers = peers
}
}
func _internal_messageReadStats(account: Account, id: MessageId) -> Signal<MessageReadStats?, NoError> {
return account.postbox.transaction { transaction -> Api.InputPeer? in
return transaction.getPeer(id.peerId).flatMap(apiInputPeer)
}
|> mapToSignal { inputPeer -> Signal<MessageReadStats?, NoError> in
guard let inputPeer = inputPeer else {
return .single(nil)
}
if id.namespace != Namespaces.Message.Cloud {
return .single(nil)
}
return account.network.request(Api.functions.messages.getMessageReadParticipants(peer: inputPeer, msgId: id.id))
|> map(Optional.init)
|> `catch` { _ -> Signal<[Int64]?, NoError> in
return .single(nil)
}
|> mapToSignal { result -> Signal<MessageReadStats?, NoError> in
guard let result = result else {
return .single(nil)
}
return account.postbox.transaction { transaction -> (peerIds: [PeerId], missingPeerIds: [PeerId]) in
var peerIds: [PeerId] = []
var missingPeerIds: [PeerId] = []
for id in result {
let peerId = PeerId(namespace: Namespaces.Peer.CloudUser, id: PeerId.Id._internalFromInt64Value(id))
if peerId == account.peerId {
continue
}
peerIds.append(peerId)
if transaction.getPeer(peerId) == nil {
missingPeerIds.append(peerId)
}
}
return (peerIds: peerIds, missingPeerIds: missingPeerIds)
}
|> mapToSignal { peerIds, missingPeerIds -> Signal<MessageReadStats?, NoError> in
if missingPeerIds.isEmpty || id.peerId.namespace != Namespaces.Peer.CloudChannel {
return account.postbox.transaction { transaction -> MessageReadStats? in
return MessageReadStats(peers: peerIds.compactMap { peerId -> EnginePeer? in
return transaction.getPeer(peerId).flatMap(EnginePeer.init)
})
}
} else {
return _internal_channelMembers(postbox: account.postbox, network: account.network, accountPeerId: account.peerId, peerId: id.peerId, category: .recent(.all), offset: 0, limit: 50, hash: 0)
|> mapToSignal { _ -> Signal<MessageReadStats?, NoError> in
return account.postbox.transaction { transaction -> MessageReadStats? in
return MessageReadStats(peers: peerIds.compactMap { peerId -> EnginePeer? in
return transaction.getPeer(peerId).flatMap(EnginePeer.init)
})
}
}
}
}
}
}
}

View File

@ -203,5 +203,9 @@ public extension TelegramEngine {
public func adMessages(peerId: PeerId) -> AdMessagesHistoryContext {
return AdMessagesHistoryContext(account: self.account, peerId: peerId)
}
public func messageReadStats(id: MessageId) -> Signal<MessageReadStats?, NoError> {
return _internal_messageReadStats(account: self.account, id: id)
}
}
}

View File

@ -18,6 +18,9 @@ import PresentationDataUtils
import TelegramPresentationData
import TelegramStringFormatting
import UndoUI
import ShimmerEffect
import AnimatedAvatarSetNode
import AvatarNode
private struct MessageContextMenuData {
let starStatus: Bool?
@ -136,6 +139,51 @@ private func canEditMessage(accountPeerId: PeerId, limitsConfiguration: LimitsCo
return false
}
private func canViewReadStats(message: Message, appConfig: AppConfiguration) -> Bool {
if message.flags.contains(.Incoming) {
return false
}
guard let peer = message.peers[message.id.peerId] else {
return false
}
for media in message.media {
if let _ = media as? TelegramMediaAction {
return false
}
}
var maxParticipantCount = 50
var maxTimeout = 7 * 86400
if let data = appConfig.data {
if let value = data["chat_read_mark_size_threshold"] as? Double {
maxParticipantCount = Int(value)
}
if let value = data["chat_read_mark_expire_period"] as? Double {
maxTimeout = Int(value)
}
}
switch peer {
case let channel as TelegramChannel:
if case .broadcast = channel.info {
return false
}
case let group as TelegramGroup:
if group.participantCount > maxParticipantCount {
return false
}
default:
return false
}
let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970)
if Int64(message.timestamp) + Int64(maxTimeout) < Int64(timestamp) {
return false
}
return true
}
func canReplyInChat(_ chatPresentationInterfaceState: ChatPresentationInterfaceState) -> Bool {
guard let peer = chatPresentationInterfaceState.renderedPeer?.peer else {
return false
@ -284,7 +332,7 @@ func updatedChatEditInterfaceMessageState(state: ChatPresentationInterfaceState,
return updated
}
func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, messages: [Message], controllerInteraction: ChatControllerInteraction?, selectAll: Bool, interfaceInteraction: ChatPanelInterfaceInteraction?) -> Signal<[ContextMenuItem], NoError> {
func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState: ChatPresentationInterfaceState, context: AccountContext, messages: [Message], controllerInteraction: ChatControllerInteraction?, selectAll: Bool, interfaceInteraction: ChatPanelInterfaceInteraction?, readStats: MessageReadStats? = nil) -> Signal<[ContextMenuItem], NoError> {
guard let interfaceInteraction = interfaceInteraction, let controllerInteraction = controllerInteraction else {
return .single([])
}
@ -422,8 +470,6 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
canPin = true
}
}
/*case .group:
break*/
}
} else {
canReply = false
@ -463,15 +509,17 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
|> map(Optional.init)
}
let loadLimits = context.account.postbox.transaction { transaction -> LimitsConfiguration in
return transaction.getPreferencesEntry(key: PreferencesKeys.limitsConfiguration) as? LimitsConfiguration ?? LimitsConfiguration.defaultValue
let loadLimits = context.account.postbox.transaction { transaction -> (LimitsConfiguration, AppConfiguration) in
let limitsConfiguration = transaction.getPreferencesEntry(key: PreferencesKeys.limitsConfiguration) as? LimitsConfiguration ?? LimitsConfiguration.defaultValue
let appConfig = transaction.getPreferencesEntry(key: PreferencesKeys.appConfiguration) as? AppConfiguration ?? AppConfiguration.defaultValue
return (limitsConfiguration, appConfig)
}
let cachedData = context.account.postbox.transaction { transaction -> CachedPeerData? in
return transaction.getPeerCachedData(peerId: messages[0].id.peerId)
}
let dataSignal: Signal<(MessageContextMenuData, [MessageId: ChatUpdatingMessageMedia], CachedPeerData?), NoError> = combineLatest(
let dataSignal: Signal<(MessageContextMenuData, [MessageId: ChatUpdatingMessageMedia], CachedPeerData?, AppConfiguration), NoError> = combineLatest(
loadLimits,
loadStickerSaveStatusSignal,
loadResourceStatusSignal,
@ -480,19 +528,20 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
|> take(1),
cachedData
)
|> map { limitsConfiguration, stickerSaveStatus, resourceStatus, messageActions, updatingMessageMedia, cachedData -> (MessageContextMenuData, [MessageId: ChatUpdatingMessageMedia], CachedPeerData?) in
|> map { limitsAndAppConfig, stickerSaveStatus, resourceStatus, messageActions, updatingMessageMedia, cachedData -> (MessageContextMenuData, [MessageId: ChatUpdatingMessageMedia], CachedPeerData?, AppConfiguration) in
let (limitsConfiguration, appConfig) = limitsAndAppConfig
var canEdit = false
if !isAction {
let message = messages[0]
canEdit = canEditMessage(context: context, limitsConfiguration: limitsConfiguration, message: message)
}
return (MessageContextMenuData(starStatus: stickerSaveStatus, canReply: canReply, canPin: canPin, canEdit: canEdit, canSelect: canSelect, resourceStatus: resourceStatus, messageActions: messageActions), updatingMessageMedia, cachedData)
return (MessageContextMenuData(starStatus: stickerSaveStatus, canReply: canReply, canPin: canPin, canEdit: canEdit, canSelect: canSelect, resourceStatus: resourceStatus, messageActions: messageActions), updatingMessageMedia, cachedData, appConfig)
}
return dataSignal
|> deliverOnMainQueue
|> map { data, updatingMessageMedia, cachedData -> [ContextMenuItem] in
|> map { data, updatingMessageMedia, cachedData, appConfig -> [ContextMenuItem] in
var actions: [ContextMenuItem] = []
var isPinnedMessages = false
@ -950,13 +999,6 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
}
}
}
/*if !isReplyThreadHead, !data.messageActions.options.intersection([.deleteLocally, .deleteGlobally]).isEmpty && isAction {
actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_ContextMenuDelete, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.actionSheet.destructiveActionTextColor)
}, action: { controller, f in
interfaceInteraction.deleteMessages(messages, controller, f)
})))
}*/
if data.messageActions.options.contains(.viewStickerPack) {
actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.StickerPack_ViewPack, icon: { theme in
@ -1012,6 +1054,28 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
clearCacheAsDelete = true
}
if !isPinnedMessages, !isReplyThreadHead, data.canSelect {
if !selectAll || messages.count == 1 {
actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_ContextMenuSelect, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.actionSheet.primaryTextColor)
}, action: { _, f in
interfaceInteraction.beginMessageSelection(selectAll ? messages.map { $0.id } : [message.id], { transition in
f(.custom(transition))
})
})))
}
if messages.count > 1 {
actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_ContextMenuSelectAll(Int32(messages.count)), icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/SelectAll"), color: theme.actionSheet.primaryTextColor)
}, action: { _, f in
interfaceInteraction.beginMessageSelection(messages.map { $0.id }, { transition in
f(.custom(transition))
})
})))
}
}
if !isReplyThreadHead, (!data.messageActions.options.intersection([.deleteLocally, .deleteGlobally]).isEmpty || clearCacheAsDelete) {
var autoremoveDeadline: Int32?
for attribute in message.attributes {
@ -1036,7 +1100,12 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
} else {
title = chatPresentationInterfaceState.strings.Conversation_ContextMenuDelete
}
if let autoremoveDeadline = autoremoveDeadline, !isEditing, !isSending {
if !actions.isEmpty {
actions.append(.separator)
}
actions.append(.custom(ChatDeleteMessageContextItem(timestamp: Double(autoremoveDeadline), action: { controller, f in
if isEditing {
context.account.pendingUpdateMessageManager.cancel(messageId: message.id)
@ -1046,6 +1115,10 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
}
}), false))
} else if !isUnremovableAction {
if !actions.isEmpty {
actions.append(.separator)
}
actions.append(.action(ContextMenuActionItem(text: title, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: isSending ? "Chat/Context Menu/Clear" : "Chat/Context Menu/Delete"), color: theme.actionSheet.destructiveActionTextColor)
}, action: { controller, f in
@ -1058,29 +1131,66 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
})))
}
}
if !isPinnedMessages, !isReplyThreadHead, data.canSelect {
if !actions.isEmpty {
actions.append(.separator)
if let peer = message.peers[message.id.peerId], canViewReadStats(message: message, appConfig: appConfig) {
var hasReadReports = false
if let channel = peer as? TelegramChannel {
if case .group = channel.info {
if let cachedData = cachedData as? CachedChannelData, let memberCount = cachedData.participantsSummary.memberCount, memberCount <= 50 {
hasReadReports = true
}
}
} else if let group = peer as? TelegramGroup {
if group.participantCount <= 50 {
hasReadReports = true
}
}
if !selectAll || messages.count == 1 {
actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_ContextMenuSelect, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.actionSheet.primaryTextColor)
}, action: { _, f in
interfaceInteraction.beginMessageSelection(selectAll ? messages.map { $0.id } : [message.id], { transition in
f(.custom(transition))
})
})))
}
if messages.count > 1 {
actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_ContextMenuSelectAll(Int32(messages.count)), icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/SelectAll"), color: theme.actionSheet.primaryTextColor)
}, action: { _, f in
interfaceInteraction.beginMessageSelection(messages.map { $0.id }, { transition in
f(.custom(transition))
})
})))
if hasReadReports {
if !actions.isEmpty {
actions.insert(.separator, at: 0)
}
actions.insert(.custom(ChatReadReportContextItem(context: context, message: message, stats: readStats, action: { c, f, stats in
if stats.peers.count == 1 {
c.dismiss(completion: {
controllerInteraction.openPeer(stats.peers[0].id, .default, nil)
})
} else if !stats.peers.isEmpty {
var subActions: [ContextMenuItem] = []
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
subActions.append(.action(ContextMenuActionItem(text: presentationData.strings.Common_Back, textColor: .primary, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor)
}, action: { controller, _ in
controller.setItems(contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState: chatPresentationInterfaceState, context: context, messages: messages, controllerInteraction: controllerInteraction, selectAll: selectAll, interfaceInteraction: interfaceInteraction, readStats: stats))
})))
let debugRepeatCount: Int
#if DEBUG
debugRepeatCount = stats.peers.count == 1 ? 1 : 50
#else
debugRepeatCount = 1
#endif
for _ in 0 ..< debugRepeatCount {
for peer in stats.peers {
let avatarSignal = peerAvatarCompleteImage(account: context.account, peer: peer._asPeer(), size: CGSize(width: 30.0, height: 30.0))
subActions.append(.action(ContextMenuActionItem(text: peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), textLayout: .singleLine, icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: CGSize(width: 30.0, height: 30.0), signal: avatarSignal), action: { _, f in
c.dismiss(completion: {
controllerInteraction.openPeer(peer.id, .default, nil)
})
})))
}
}
c.setItems(.single(subActions))
} else {
f(.default)
}
}), false), at: 0)
}
}
@ -1394,6 +1504,10 @@ private final class ChatDeleteMessageContextItemNode: ASDisplayNode, ContextMenu
private var pointerInteraction: PointerInteraction?
var isActionEnabled: Bool {
return true
}
init(presentationData: PresentationData, item: ChatDeleteMessageContextItem, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) {
self.item = item
self.presentationData = presentationData
@ -1569,6 +1683,279 @@ private final class ChatDeleteMessageContextItemNode: ASDisplayNode, ContextMenu
}
}
final class ChatReadReportContextItem: ContextMenuCustomItem {
fileprivate let context: AccountContext
fileprivate let message: Message
fileprivate let stats: MessageReadStats?
fileprivate let action: (ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void, MessageReadStats) -> Void
init(context: AccountContext, message: Message, stats: MessageReadStats?, action: @escaping (ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void, MessageReadStats) -> Void) {
self.context = context
self.message = message
self.stats = stats
self.action = action
}
func node(presentationData: PresentationData, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) -> ContextMenuCustomNode {
return ChatReadReportContextItemNode(presentationData: presentationData, item: self, getController: getController, actionSelected: actionSelected)
}
}
private final class ChatReadReportContextItemNode: ASDisplayNode, ContextMenuCustomNode, ContextActionNodeProtocol {
private let item: ChatReadReportContextItem
private var presentationData: PresentationData
private let getController: () -> ContextControllerProtocol?
private let actionSelected: (ContextMenuActionResult) -> Void
private let backgroundNode: ASDisplayNode
private let highlightedBackgroundNode: ASDisplayNode
private let textNode: ImmediateTextNode
private let shimmerNode: ShimmerEffectNode
private let iconNode: ASImageNode
private let avatarsNode: AnimatedAvatarSetNode
private let avatarsContext: AnimatedAvatarSetContext
private let placeholderAvatarsNode: AnimatedAvatarSetNode
private let placeholderAvatarsContext: AnimatedAvatarSetContext
private let buttonNode: HighlightTrackingButtonNode
private var pointerInteraction: PointerInteraction?
private var disposable: Disposable?
private var currentStats: MessageReadStats?
init(presentationData: PresentationData, item: ChatReadReportContextItem, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) {
self.item = item
self.presentationData = presentationData
self.getController = getController
self.actionSelected = actionSelected
self.currentStats = item.stats
let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize)
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isAccessibilityElement = false
self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor
self.highlightedBackgroundNode = ASDisplayNode()
self.highlightedBackgroundNode.isAccessibilityElement = false
self.highlightedBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor
self.highlightedBackgroundNode.alpha = 0.0
self.textNode = ImmediateTextNode()
self.textNode.isAccessibilityElement = false
self.textNode.isUserInteractionEnabled = false
self.textNode.displaysAsynchronously = false
self.textNode.attributedText = NSAttributedString(string: " ", font: textFont, textColor: presentationData.theme.contextMenu.destructiveColor)
self.textNode.maximumNumberOfLines = 1
self.textNode.alpha = 0.0
self.shimmerNode = ShimmerEffectNode()
self.shimmerNode.clipsToBounds = true
self.buttonNode = HighlightTrackingButtonNode()
self.buttonNode.isAccessibilityElement = true
self.buttonNode.accessibilityLabel = presentationData.strings.VoiceChat_StopRecording
self.iconNode = ASImageNode()
self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: presentationData.theme.actionSheet.primaryTextColor)
self.avatarsNode = AnimatedAvatarSetNode()
self.avatarsContext = AnimatedAvatarSetContext()
self.placeholderAvatarsNode = AnimatedAvatarSetNode()
self.placeholderAvatarsContext = AnimatedAvatarSetContext()
super.init()
self.addSubnode(self.backgroundNode)
self.addSubnode(self.highlightedBackgroundNode)
self.addSubnode(self.shimmerNode)
self.addSubnode(self.textNode)
self.addSubnode(self.iconNode)
self.addSubnode(self.avatarsNode)
self.addSubnode(self.placeholderAvatarsNode)
self.addSubnode(self.buttonNode)
self.buttonNode.highligthedChanged = { [weak self] highligted in
guard let strongSelf = self else {
return
}
if highligted {
strongSelf.highlightedBackgroundNode.alpha = 1.0
} else {
strongSelf.highlightedBackgroundNode.alpha = 0.0
strongSelf.highlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3)
}
}
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside)
if let currentStats = self.currentStats {
self.buttonNode.isUserInteractionEnabled = !currentStats.peers.isEmpty
} else {
self.buttonNode.isUserInteractionEnabled = false
self.disposable = (item.context.engine.messages.messageReadStats(id: item.message.id)
|> deliverOnMainQueue).start(next: { [weak self] value in
guard let strongSelf = self else {
return
}
if let value = value {
strongSelf.updateStats(stats: value, transition: .animated(duration: 0.2, curve: .easeInOut))
}
})
}
}
deinit {
self.disposable?.dispose()
}
override func didLoad() {
super.didLoad()
self.pointerInteraction = PointerInteraction(node: self.buttonNode, style: .hover, willEnter: { [weak self] in
if let strongSelf = self {
strongSelf.highlightedBackgroundNode.alpha = 0.75
}
}, willExit: { [weak self] in
if let strongSelf = self {
strongSelf.highlightedBackgroundNode.alpha = 0.0
}
})
}
private var validLayout: (calculatedWidth: CGFloat, size: CGSize)?
func updateStats(stats: MessageReadStats, transition: ContainedViewLayoutTransition) {
self.buttonNode.isUserInteractionEnabled = !stats.peers.isEmpty
guard let (calculatedWidth, size) = self.validLayout else {
return
}
self.currentStats = stats
let (_, apply) = self.updateLayout(constrainedWidth: calculatedWidth)
apply(size, transition)
}
func updateLayout(constrainedWidth: CGFloat) -> (CGSize, (CGSize, ContainedViewLayoutTransition) -> Void) {
let sideInset: CGFloat = 14.0
let verticalInset: CGFloat = 12.0
let iconSize: CGSize = self.iconNode.image?.size ?? CGSize(width: 10.0, height: 10.0)
let rightTextInset: CGFloat = sideInset + 36.0
let calculatedWidth = min(constrainedWidth, 260.0)
let textFont = Font.regular(self.presentationData.listsFontSize.baseDisplaySize)
if let currentStats = self.currentStats {
if currentStats.peers.isEmpty {
//TODO:localize
self.textNode.attributedText = NSAttributedString(string: "No Views", font: textFont, textColor: self.presentationData.theme.contextMenu.secondaryColor)
} else if currentStats.peers.count == 1 {
self.textNode.attributedText = NSAttributedString(string: currentStats.peers[0].displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder), font: textFont, textColor: self.presentationData.theme.contextMenu.primaryColor)
} else {
self.textNode.attributedText = NSAttributedString(string: self.presentationData.strings.Conversation_ContextMenuSeen(Int32(currentStats.peers.count)), font: textFont, textColor: self.presentationData.theme.contextMenu.primaryColor)
}
} else {
self.textNode.attributedText = NSAttributedString(string: " ", font: textFont, textColor: self.presentationData.theme.contextMenu.primaryColor)
}
let textSize = self.textNode.updateLayout(CGSize(width: calculatedWidth - sideInset - rightTextInset - iconSize.width - 4.0, height: .greatestFiniteMagnitude))
let combinedTextHeight = textSize.height
return (CGSize(width: calculatedWidth, height: verticalInset * 2.0 + combinedTextHeight), { size, transition in
self.validLayout = (calculatedWidth: calculatedWidth, size: size)
let verticalOrigin = floor((size.height - combinedTextHeight) / 2.0)
let textFrame = CGRect(origin: CGPoint(x: sideInset + iconSize.width + 4.0, y: verticalOrigin), size: textSize)
transition.updateFrameAdditive(node: self.textNode, frame: textFrame)
transition.updateAlpha(node: self.textNode, alpha: self.currentStats == nil ? 0.0 : 1.0)
let shimmerHeight: CGFloat = 8.0
self.shimmerNode.frame = CGRect(origin: CGPoint(x: textFrame.minX, y: floor((size.height - shimmerHeight) / 2.0)), size: CGSize(width: min(100.0, size.width - 40.0), height: shimmerHeight))
self.shimmerNode.cornerRadius = shimmerHeight / 2.0
let shimmeringForegroundColor = self.presentationData.theme.contextMenu.itemSeparatorColor.blitOver(self.presentationData.theme.list.plainBackgroundColor, alpha: 0.9)
let shimmeringColor = self.presentationData.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.2)
self.shimmerNode.update(backgroundColor: self.presentationData.theme.list.plainBackgroundColor, foregroundColor: shimmeringForegroundColor, shimmeringColor: shimmeringColor, shapes: [.rect(rect: self.shimmerNode.bounds)], horizontal: true, size: self.shimmerNode.bounds.size)
self.shimmerNode.updateAbsoluteRect(self.shimmerNode.frame, within: size)
transition.updateAlpha(node: self.shimmerNode, alpha: self.currentStats == nil ? 1.0 : 0.0)
if !iconSize.width.isZero {
transition.updateFrameAdditive(node: self.iconNode, frame: CGRect(origin: CGPoint(x: sideInset - 2.0, y: floor((size.height - iconSize.height) / 2.0)), size: iconSize))
}
let avatarsContent: AnimatedAvatarSetContext.Content
let placeholderAvatarsContent: AnimatedAvatarSetContext.Content
var avatarsPeers: [EnginePeer] = []
if let peers = self.currentStats?.peers {
for i in 0 ..< min(3, peers.count) {
avatarsPeers.append(peers[i])
}
}
avatarsContent = self.avatarsContext.update(peers: avatarsPeers, animated: false)
placeholderAvatarsContent = self.avatarsContext.updatePlaceholder(color: shimmeringForegroundColor, count: 3, animated: false)
let avatarsSize = self.avatarsNode.update(context: self.item.context, content: avatarsContent, itemSize: CGSize(width: 24.0, height: 24.0), customSpacing: 10.0, animated: false, synchronousLoad: true)
self.avatarsNode.frame = CGRect(origin: CGPoint(x: size.width - sideInset - 8.0 - avatarsSize.width, y: floor((size.height - avatarsSize.height) / 2.0)), size: avatarsSize)
transition.updateAlpha(node: self.avatarsNode, alpha: self.currentStats == nil ? 0.0 : 1.0)
let placeholderAvatarsSize = self.placeholderAvatarsNode.update(context: self.item.context, content: placeholderAvatarsContent, itemSize: CGSize(width: 24.0, height: 24.0), customSpacing: 10.0, animated: false, synchronousLoad: true)
self.placeholderAvatarsNode.frame = CGRect(origin: CGPoint(x: size.width - sideInset - 8.0 - placeholderAvatarsSize.width, y: floor((size.height - placeholderAvatarsSize.height) / 2.0)), size: placeholderAvatarsSize)
transition.updateAlpha(node: self.placeholderAvatarsNode, alpha: self.currentStats == nil ? 1.0 : 0.0)
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
transition.updateFrame(node: self.highlightedBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
transition.updateFrame(node: self.buttonNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)))
})
}
func updateTheme(presentationData: PresentationData) {
self.presentationData = presentationData
self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor
self.highlightedBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor
let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize)
self.textNode.attributedText = NSAttributedString(string: self.textNode.attributedText?.string ?? "", font: textFont, textColor: presentationData.theme.contextMenu.primaryColor)
}
@objc private func buttonPressed() {
self.performAction()
}
func performAction() {
guard let controller = self.getController(), let currentStats = self.currentStats else {
return
}
self.item.action(controller, { [weak self] result in
self?.actionSelected(result)
}, currentStats)
}
var isActionEnabled: Bool {
guard let currentStats = self.currentStats else {
return false
}
return !currentStats.peers.isEmpty
}
func setIsHighlighted(_ value: Bool) {
if value {
self.highlightedBackgroundNode.alpha = 1.0
} else {
self.highlightedBackgroundNode.alpha = 0.0
}
}
}
private func stringForRemainingTime(_ duration: Int32, strings: PresentationStrings) -> String {
let days = duration / (3600 * 24)
let hours = duration / 3600