import Foundation import Postbox import SwiftSignalKit import TelegramApi import MtProtoKit public enum UpdateMessageReaction { case builtin(String) case custom(fileId: Int64, file: TelegramMediaFile?) case stars public var reaction: MessageReaction.Reaction { switch self { case let .builtin(value): return .builtin(value) case let .custom(fileId, _): return .custom(fileId) case .stars: return .stars } } } public func updateMessageReactionsInteractively(account: Account, messageIds: [MessageId], reactions: [UpdateMessageReaction], isLarge: Bool, storeAsRecentlyUsed: Bool, add: Bool = false) -> Signal { return account.postbox.transaction { transaction -> Void in guard let chatPeerId = messageIds.first?.peerId else { return } var messagesWithoutGroups: [Message] = [] var messagesByGroupId: [Int64: [Message]] = [:] let messages = messageIds.compactMap { transaction.getMessage($0) } for message in messages { if let groupingKey = message.groupingKey { if messagesByGroupId[groupingKey] == nil { messagesByGroupId[groupingKey] = [message] } else { messagesByGroupId[groupingKey]?.append(message) } } else { messagesWithoutGroups.append(message) } } var messageIds: [MessageId] = [] for message in messagesWithoutGroups { messageIds.append(message.id) } for (_, messages) in messagesByGroupId { if let minMessageId = messages.map(\.id).min() { messageIds.append(minMessageId) } } var sendAsPeerId = account.peerId if let cachedData = transaction.getPeerCachedData(peerId: chatPeerId) { if let cachedData = cachedData as? CachedChannelData { if let sendAsPeerIdValue = cachedData.sendAsPeerId { sendAsPeerId = sendAsPeerIdValue } } } let isPremium = (transaction.getPeer(account.peerId) as? TelegramUser)?.isPremium ?? false let appConfiguration = transaction.getPreferencesEntry(key: PreferencesKeys.appConfiguration)?.get(AppConfiguration.self) ?? .defaultValue let maxCount: Int if isPremium { let limitsConfiguration = UserLimitsConfiguration(appConfiguration: appConfiguration, isPremium: isPremium) maxCount = Int(limitsConfiguration.maxReactionsPerMessage) } else { maxCount = 1 } for messageId in messageIds { var mappedReactions: [PendingReactionsMessageAttribute.PendingReaction] = [] var reactions: [UpdateMessageReaction] = reactions if add { if let message = transaction.getMessage(messageId), let effectiveReactions = message.effectiveReactions(isTags: message.areReactionsTags(accountPeerId: account.peerId)) { for reaction in effectiveReactions { if !reactions.contains(where: { $0.reaction == reaction.value }) { let mappedValue: UpdateMessageReaction switch reaction.value { case let .builtin(value): mappedValue = .builtin(value) case let .custom(fileId): mappedValue = .custom(fileId: fileId, file: nil) case .stars: mappedValue = .stars } reactions.append(mappedValue) } } } } for reaction in reactions { switch reaction { case let .custom(fileId, file): mappedReactions.append(PendingReactionsMessageAttribute.PendingReaction(value: .custom(fileId), sendAsPeerId: sendAsPeerId)) if let file = file { transaction.storeMediaIfNotPresent(media: file) } case let .builtin(value): mappedReactions.append(PendingReactionsMessageAttribute.PendingReaction(value: .builtin(value), sendAsPeerId: sendAsPeerId)) case .stars: mappedReactions.append(PendingReactionsMessageAttribute.PendingReaction(value: .stars, sendAsPeerId: sendAsPeerId)) } } transaction.setPendingMessageAction(type: .updateReaction, id: messageId, action: UpdateMessageReactionsAction()) transaction.updateMessage(messageId, update: { currentMessage in var storeForwardInfo: StoreMessageForwardInfo? if let forwardInfo = currentMessage.forwardInfo { storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags) } var attributes = currentMessage.attributes loop: for j in 0 ..< attributes.count { if let _ = attributes[j] as? PendingReactionsMessageAttribute { attributes.remove(at: j) break loop } } if storeAsRecentlyUsed { let isTags = currentMessage.areReactionsTags(accountPeerId: account.peerId) if !isTags { let effectiveReactions = currentMessage.effectiveReactions(isTags: isTags) ?? [] for updatedReaction in reactions { if !effectiveReactions.contains(where: { $0.value == updatedReaction.reaction && $0.isSelected }) { let recentReactionItem: RecentReactionItem switch updatedReaction { case let .builtin(value): recentReactionItem = RecentReactionItem(.builtin(value)) case let .custom(fileId, file): if let file = file ?? (transaction.getMedia(MediaId(namespace: Namespaces.Media.CloudFile, id: fileId)) as? TelegramMediaFile) { recentReactionItem = RecentReactionItem(.custom(TelegramMediaFile.Accessor(file))) } else { continue } case .stars: recentReactionItem = RecentReactionItem(.stars) } if let entry = CodableEntry(recentReactionItem) { let itemEntry = OrderedItemListEntry(id: recentReactionItem.id.rawValue, contents: entry) transaction.addOrMoveToFirstPositionOrderedItemListItem(collectionId: Namespaces.OrderedItemList.CloudRecentReactions, item: itemEntry, removeTailIfCountExceeds: 50) } } } } } var mappedReactions = mappedReactions let updatedReactions = mergedMessageReactions(attributes: attributes + [PendingReactionsMessageAttribute(accountPeerId: account.peerId, reactions: mappedReactions, isLarge: isLarge, storeAsRecentlyUsed: storeAsRecentlyUsed, isTags: currentMessage.areReactionsTags(accountPeerId: account.peerId))], isTags: currentMessage.areReactionsTags(accountPeerId: account.peerId))?.reactions ?? [] let updatedOutgoingReactions = updatedReactions.filter(\.isSelected) if updatedOutgoingReactions.count > maxCount { let sortedOutgoingReactions = updatedOutgoingReactions.sorted(by: { $0.chosenOrder! < $1.chosenOrder! }) mappedReactions = Array(sortedOutgoingReactions.suffix(maxCount).map { reaction -> PendingReactionsMessageAttribute.PendingReaction in return PendingReactionsMessageAttribute.PendingReaction(value: reaction.value, sendAsPeerId: sendAsPeerId) }) } attributes.append(PendingReactionsMessageAttribute(accountPeerId: account.peerId, reactions: mappedReactions, isLarge: isLarge, storeAsRecentlyUsed: storeAsRecentlyUsed, isTags: currentMessage.areReactionsTags(accountPeerId: account.peerId))) return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) }) } } |> ignoreValues } func _internal_sendStarsReactionsInteractively(account: Account, messageId: MessageId, count: Int, privacy: TelegramPaidReactionPrivacy?) -> Signal { return account.postbox.transaction { transaction -> TelegramPaidReactionPrivacy in transaction.setPendingMessageAction(type: .sendStarsReaction, id: messageId, action: SendStarsReactionsAction(randomId: Int64.random(in: Int64.min ... Int64.max))) var resolvedPrivacyValue: TelegramPaidReactionPrivacy = .default transaction.updateMessage(messageId, update: { currentMessage in var storeForwardInfo: StoreMessageForwardInfo? if let forwardInfo = currentMessage.forwardInfo { storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags) } var mappedCount = Int32(count) var attributes = currentMessage.attributes var resolvedPrivacy = _internal_getStarsReactionDefaultPrivacy(transaction: transaction) for attribute in attributes { if let attribute = attribute as? ReactionsMessageAttribute { if let myReaction = attribute.topPeers.first(where: { $0.isMy }) { if myReaction.isAnonymous { resolvedPrivacy = .anonymous } else if myReaction.peerId == account.peerId { resolvedPrivacy = .default } else if let peerId = myReaction.peerId { resolvedPrivacy = .peer(peerId) } else { resolvedPrivacy = .anonymous } } } } loop: for j in 0 ..< attributes.count { if let current = attributes[j] as? PendingStarsReactionsMessageAttribute { mappedCount += current.count resolvedPrivacy = current.privacy attributes.remove(at: j) break loop } } if let privacy { resolvedPrivacy = privacy _internal_setStarsReactionDefaultPrivacy(privacy: privacy, transaction: transaction) } attributes.append(PendingStarsReactionsMessageAttribute(accountPeerId: account.peerId, count: mappedCount, privacy: resolvedPrivacy)) resolvedPrivacyValue = resolvedPrivacy return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) }) return resolvedPrivacyValue } } func cancelPendingSendStarsReactionInteractively(account: Account, messageId: MessageId) -> Signal { return account.postbox.transaction { transaction -> Void in transaction.setPendingMessageAction(type: .sendStarsReaction, id: messageId, action: nil) transaction.updateMessage(messageId, update: { currentMessage in var storeForwardInfo: StoreMessageForwardInfo? if let forwardInfo = currentMessage.forwardInfo { storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags) } var attributes = currentMessage.attributes loop: for j in 0 ..< attributes.count { if let _ = attributes[j] as? PendingStarsReactionsMessageAttribute { attributes.remove(at: j) break loop } } return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) }) } |> ignoreValues } func _internal_forceSendPendingSendStarsReaction(account: Account, messageId: MessageId) -> Signal { account.stateManager.forceSendPendingStarsReaction(messageId: messageId) return .complete() } func _internal_updateStarsReactionPrivacy(account: Account, messageId: MessageId, privacy: TelegramPaidReactionPrivacy) -> Signal { return account.postbox.transaction { transaction -> (Api.InputPeer?, Api.InputPeer?) in _internal_setStarsReactionDefaultPrivacy(privacy: privacy, transaction: transaction) transaction.updateMessage(messageId, update: { currentMessage in var storeForwardInfo: StoreMessageForwardInfo? if let forwardInfo = currentMessage.forwardInfo { storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags) } var attributes = currentMessage.attributes for j in (0 ..< attributes.count).reversed() { if let attribute = attributes[j] as? ReactionsMessageAttribute { var updatedTopPeers = attribute.topPeers if let index = updatedTopPeers.firstIndex(where: { $0.isMy }) { switch privacy { case .anonymous: updatedTopPeers[index].isAnonymous = true updatedTopPeers[index].peerId = nil case .default: updatedTopPeers[index].isAnonymous = false updatedTopPeers[index].peerId = account.peerId case let .peer(peerId): updatedTopPeers[index].isAnonymous = false updatedTopPeers[index].peerId = peerId } } attributes[j] = ReactionsMessageAttribute(canViewList: attribute.canViewList, isTags: attribute.isTags, reactions: attribute.reactions, recentPeers: attribute.recentPeers, topPeers: updatedTopPeers) } } return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) }) var privacyPeerId: PeerId? if case let .peer(peerId) = privacy { privacyPeerId = peerId } return ( transaction.getPeer(messageId.peerId).flatMap(apiInputPeer), privacyPeerId.flatMap { privacyPeerId in transaction.getPeer(privacyPeerId).flatMap(apiInputPeer) } ) } |> mapToSignal { inputPeer, inputPrivacyPeer -> Signal in guard let inputPeer else { return .complete() } let mappedPrivacy: Api.PaidReactionPrivacy switch privacy { case .anonymous: mappedPrivacy = .paidReactionPrivacyAnonymous case .default: mappedPrivacy = .paidReactionPrivacyDefault case .peer: guard let inputPrivacyPeer else { return .complete() } mappedPrivacy = .paidReactionPrivacyPeer(peer: inputPrivacyPeer) } return account.network.request(Api.functions.messages.togglePaidReactionPrivacy(peer: inputPeer, msgId: messageId.id, private: mappedPrivacy)) |> `catch` { _ -> Signal in return .single(.boolFalse) } |> ignoreValues } } private enum RequestUpdateMessageReactionError { case generic } private func requestUpdateMessageReaction(postbox: Postbox, network: Network, stateManager: AccountStateManager, messageId: MessageId) -> Signal { return postbox.transaction { transaction -> (Peer, [MessageReaction.Reaction]?, Bool, Bool)? in guard let peer = transaction.getPeer(messageId.peerId) else { return nil } guard let message = transaction.getMessage(messageId) else { return nil } var reactions: [MessageReaction.Reaction]? var isLarge: Bool = false var storeAsRecentlyUsed: Bool = false for attribute in message.attributes { if let attribute = attribute as? PendingReactionsMessageAttribute { if !attribute.reactions.isEmpty { reactions = attribute.reactions.map(\.value) } isLarge = attribute.isLarge storeAsRecentlyUsed = attribute.storeAsRecentlyUsed break } } return (peer, reactions, isLarge, storeAsRecentlyUsed) } |> castError(RequestUpdateMessageReactionError.self) |> mapToSignal { peerAndValue in guard let (peer, reactions, isLarge, storeAsRecentlyUsed) = peerAndValue else { return .fail(.generic) } guard let inputPeer = apiInputPeer(peer) else { return .fail(.generic) } if messageId.namespace != Namespaces.Message.Cloud { return .fail(.generic) } var flags: Int32 = 0 if reactions != nil { flags |= 1 << 0 if isLarge { flags |= 1 << 1 } if storeAsRecentlyUsed { flags |= 1 << 2 } } let signal: Signal = network.request(Api.functions.messages.sendReaction(flags: flags, peer: inputPeer, msgId: messageId.id, reaction: reactions?.map(\.apiReaction))) |> mapError { _ -> RequestUpdateMessageReactionError in return .generic } |> mapToSignal { result -> Signal in return postbox.transaction { transaction -> Void in transaction.setPendingMessageAction(type: .updateReaction, id: messageId, action: UpdateMessageReactionsAction()) transaction.updateMessage(messageId, update: { currentMessage in var storeForwardInfo: StoreMessageForwardInfo? if let forwardInfo = currentMessage.forwardInfo { storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags) } let reactions = mergedMessageReactions(attributes: currentMessage.attributes, isTags: currentMessage.areReactionsTags(accountPeerId: stateManager.accountPeerId)) var attributes = currentMessage.attributes for j in (0 ..< attributes.count).reversed() { if attributes[j] is PendingReactionsMessageAttribute || attributes[j] is ReactionsMessageAttribute { attributes.remove(at: j) } } if let reactions = reactions { attributes.append(reactions) } return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) }) stateManager.addUpdates(result) } |> castError(RequestUpdateMessageReactionError.self) |> ignoreValues } #if DEBUG return signal |> delay(0.1, queue: .mainQueue()) #else return signal #endif } } private func requestSendStarsReaction(postbox: Postbox, network: Network, stateManager: AccountStateManager, messageId: MessageId) -> Signal { return postbox.transaction { transaction -> (Peer, Int32, Api.PaidReactionPrivacy)? in guard let peer = transaction.getPeer(messageId.peerId) else { return nil } guard let message = transaction.getMessage(messageId) else { return nil } var count: Int32 = 0 var privacy: Api.PaidReactionPrivacy = .paidReactionPrivacyDefault for attribute in message.attributes { if let attribute = attribute as? PendingStarsReactionsMessageAttribute { count += attribute.count let mappedPrivacy: Api.PaidReactionPrivacy switch attribute.privacy { case .anonymous: mappedPrivacy = .paidReactionPrivacyAnonymous case .default: mappedPrivacy = .paidReactionPrivacyDefault case let .peer(peerId): guard let inputPrivacyPeer = transaction.getPeer(peerId).flatMap(apiInputPeer) else { return nil } mappedPrivacy = .paidReactionPrivacyPeer(peer: inputPrivacyPeer) } privacy = mappedPrivacy break } } return (peer, count, privacy) } |> castError(RequestUpdateMessageReactionError.self) |> mapToSignal { peerAndValue in guard let (peer, count, privacy) = peerAndValue else { return .fail(.generic) } guard let inputPeer = apiInputPeer(peer) else { return .fail(.generic) } if messageId.namespace != Namespaces.Message.Cloud { return .fail(.generic) } if count > 0 { let randomPartId = UInt64(UInt32(bitPattern: Int32.random(in: Int32.min ... Int32.max))) let timestampPart = UInt64(UInt32(bitPattern: Int32(Date().timeIntervalSince1970))) let randomId = (timestampPart << 32) | randomPartId var flags: Int32 = 0 flags |= 1 << 0 let signal: Signal = network.request(Api.functions.messages.sendPaidReaction(flags: flags, peer: inputPeer, msgId: messageId.id, count: count, randomId: Int64(bitPattern: randomId), private: privacy)) |> mapError { _ -> RequestUpdateMessageReactionError in return .generic } |> mapToSignal { result -> Signal in stateManager.starsContext?.add(balance: StarsAmount(value: Int64(-count), nanos: 0), addTransaction: false) return postbox.transaction { transaction -> Void in transaction.setPendingMessageAction(type: .sendStarsReaction, id: messageId, action: UpdateMessageReactionsAction()) transaction.updateMessage(messageId, update: { currentMessage in var storeForwardInfo: StoreMessageForwardInfo? if let forwardInfo = currentMessage.forwardInfo { storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags) } let reactions = mergedMessageReactions(attributes: currentMessage.attributes, isTags: currentMessage.areReactionsTags(accountPeerId: stateManager.accountPeerId)) var attributes = currentMessage.attributes for j in (0 ..< attributes.count).reversed() { if attributes[j] is PendingStarsReactionsMessageAttribute || attributes[j] is ReactionsMessageAttribute { attributes.remove(at: j) } } if let reactions { attributes.append(reactions) } return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) }) stateManager.addUpdates(result) } |> castError(RequestUpdateMessageReactionError.self) |> ignoreValues } return signal } else { return .complete() } } } private final class ManagedApplyPendingMessageReactionsActionsHelper { var operationDisposables: [MessageId: (PendingMessageActionData, Disposable)] = [:] func update(entries: [PendingMessageActionsEntry]) -> (disposeOperations: [Disposable], beginOperations: [(PendingMessageActionsEntry, MetaDisposable)]) { var disposeOperations: [Disposable] = [] var beginOperations: [(PendingMessageActionsEntry, MetaDisposable)] = [] var hasRunningOperationForPeerId = Set() var validIds = Set() for entry in entries { if let current = self.operationDisposables[entry.id], !current.0.isEqual(to: entry.action) { self.operationDisposables.removeValue(forKey: entry.id) disposeOperations.append(current.1) } if !hasRunningOperationForPeerId.contains(entry.id.peerId) { hasRunningOperationForPeerId.insert(entry.id.peerId) validIds.insert(entry.id) let disposable = MetaDisposable() beginOperations.append((entry, disposable)) self.operationDisposables[entry.id] = (entry.action, disposable) } } var removeMergedIds: [MessageId] = [] for (id, actionAndDisposable) in self.operationDisposables { if !validIds.contains(id) { removeMergedIds.append(id) disposeOperations.append(actionAndDisposable.1) } } for id in removeMergedIds { self.operationDisposables.removeValue(forKey: id) } return (disposeOperations, beginOperations) } func reset() -> [Disposable] { let disposables = Array(self.operationDisposables.values.map(\.1)) self.operationDisposables.removeAll() return disposables } } private func withTakenReactionsAction(postbox: Postbox, type: PendingMessageActionType, id: MessageId, _ f: @escaping (Transaction, PendingMessageActionsEntry?) -> Signal) -> Signal { return postbox.transaction { transaction -> Signal in var result: PendingMessageActionsEntry? if let action = transaction.getPendingMessageAction(type: type, id: id) as? UpdateMessageReactionsAction { result = PendingMessageActionsEntry(id: id, action: action) } return f(transaction, result) } |> switchToLatest } private func withTakenStarsAction(postbox: Postbox, type: PendingMessageActionType, id: MessageId, _ f: @escaping (Transaction, PendingMessageActionsEntry?) -> Signal) -> Signal { return postbox.transaction { transaction -> Signal in var result: PendingMessageActionsEntry? if let action = transaction.getPendingMessageAction(type: type, id: id) as? SendStarsReactionsAction { result = PendingMessageActionsEntry(id: id, action: action) } return f(transaction, result) } |> switchToLatest } func managedApplyPendingMessageReactionsActions(postbox: Postbox, network: Network, stateManager: AccountStateManager) -> Signal { return Signal { _ in let helper = Atomic(value: ManagedApplyPendingMessageReactionsActionsHelper()) let actionsKey = PostboxViewKey.pendingMessageActions(type: .updateReaction) let disposable = postbox.combinedView(keys: [actionsKey]).start(next: { view in var entries: [PendingMessageActionsEntry] = [] if let v = view.views[actionsKey] as? PendingMessageActionsView { entries = v.entries } let (disposeOperations, beginOperations) = helper.with { helper -> (disposeOperations: [Disposable], beginOperations: [(PendingMessageActionsEntry, MetaDisposable)]) in return helper.update(entries: entries) } for disposable in disposeOperations { disposable.dispose() } for (entry, disposable) in beginOperations { let signal = withTakenReactionsAction(postbox: postbox, type: .updateReaction, id: entry.id, { transaction, entry -> Signal in if let entry = entry { if let _ = entry.action as? UpdateMessageReactionsAction { return synchronizeMessageReactions(transaction: transaction, postbox: postbox, network: network, stateManager: stateManager, id: entry.id) } else { assertionFailure() } } return .complete() }) |> then( postbox.transaction { transaction -> Void in transaction.setPendingMessageAction(type: .updateReaction, id: entry.id, action: nil) } |> ignoreValues ) disposable.set(signal.start()) } }) return ActionDisposable { let disposables = helper.with { helper -> [Disposable] in return helper.reset() } for disposable in disposables { disposable.dispose() } disposable.dispose() } } } func managedApplyPendingMessageStarsReactionsActions(postbox: Postbox, network: Network, stateManager: AccountStateManager) -> Signal { return Signal { _ in let helper = Atomic(value: ManagedApplyPendingMessageReactionsActionsHelper()) let actionsKey = PostboxViewKey.pendingMessageActions(type: .sendStarsReaction) let disposable = postbox.combinedView(keys: [actionsKey]).start(next: { view in var entries: [PendingMessageActionsEntry] = [] if let v = view.views[actionsKey] as? PendingMessageActionsView { entries = v.entries } let (disposeOperations, beginOperations) = helper.with { helper -> (disposeOperations: [Disposable], beginOperations: [(PendingMessageActionsEntry, MetaDisposable)]) in return helper.update(entries: entries) } for disposable in disposeOperations { disposable.dispose() } for (entry, disposable) in beginOperations { let signal = withTakenStarsAction(postbox: postbox, type: .sendStarsReaction, id: entry.id, { transaction, entry -> Signal in if let entry = entry { if let _ = entry.action as? SendStarsReactionsAction { let triggerSignal: Signal = stateManager.forceSendPendingStarsReaction |> filter { $0 == entry.id } |> map { _ -> Void in return Void() } |> take(1) |> timeout(5.0, queue: .mainQueue(), alternate: .single(Void())) return triggerSignal |> mapToSignal { _ -> Signal in return synchronizeMessageStarsReactions(transaction: transaction, postbox: postbox, network: network, stateManager: stateManager, id: entry.id) } } else { assertionFailure() } } return .complete() }) |> then( postbox.transaction { transaction -> Void in transaction.setPendingMessageAction(type: .sendStarsReaction, id: entry.id, action: nil) } |> ignoreValues ) disposable.set(signal.start()) } }) return ActionDisposable { let disposables = helper.with { helper -> [Disposable] in return helper.reset() } for disposable in disposables { disposable.dispose() } disposable.dispose() } } } private func synchronizeMessageReactions(transaction: Transaction, postbox: Postbox, network: Network, stateManager: AccountStateManager, id: MessageId) -> Signal { return requestUpdateMessageReaction(postbox: postbox, network: network, stateManager: stateManager, messageId: id) |> `catch` { _ -> Signal in return postbox.transaction { transaction -> Void in transaction.setPendingMessageAction(type: .updateReaction, id: id, action: nil) transaction.updateMessage(id, update: { currentMessage in var storeForwardInfo: StoreMessageForwardInfo? if let forwardInfo = currentMessage.forwardInfo { storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags) } var attributes = currentMessage.attributes loop: for j in 0 ..< attributes.count { if let _ = attributes[j] as? PendingReactionsMessageAttribute { attributes.remove(at: j) break loop } } return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) }) } |> ignoreValues } } private func synchronizeMessageStarsReactions(transaction: Transaction, postbox: Postbox, network: Network, stateManager: AccountStateManager, id: MessageId) -> Signal { return requestSendStarsReaction(postbox: postbox, network: network, stateManager: stateManager, messageId: id) |> `catch` { _ -> Signal in return postbox.transaction { transaction -> Void in transaction.setPendingMessageAction(type: .sendStarsReaction, id: id, action: nil) transaction.updateMessage(id, update: { currentMessage in var storeForwardInfo: StoreMessageForwardInfo? if let forwardInfo = currentMessage.forwardInfo { storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags) } var attributes = currentMessage.attributes loop: for j in 0 ..< attributes.count { if let _ = attributes[j] as? PendingStarsReactionsMessageAttribute { attributes.remove(at: j) break loop } } return .update(StoreMessage(id: currentMessage.id, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) }) } |> ignoreValues } } public extension EngineMessageReactionListContext.State { init(message: EngineMessage, readStats: MessageReadStats?, reaction: MessageReaction.Reaction?) { var totalCount = 0 var hasOutgoingReaction = false var items: [EngineMessageReactionListContext.Item] = [] if let reactionsAttribute = message._asMessage().reactionsAttribute { for messageReaction in reactionsAttribute.reactions { if reaction == nil || messageReaction.value == reaction { if messageReaction.chosenOrder != nil { hasOutgoingReaction = true } totalCount += Int(messageReaction.count) } } for recentPeer in reactionsAttribute.recentPeers { if let peer = message.peers[recentPeer.peerId] { if reaction == nil || recentPeer.value == reaction { items.append(EngineMessageReactionListContext.Item(peer: EnginePeer(peer), reaction: recentPeer.value, timestamp: recentPeer.timestamp ?? readStats?.readTimestamps[peer.id], timestampIsReaction: recentPeer.timestamp != nil)) } } } } if items.count != totalCount { items.removeAll() } self.init( hasOutgoingReaction: hasOutgoingReaction, totalCount: totalCount, items: items, canLoadMore: items.count != totalCount && totalCount != 0 ) } } public final class EngineMessageReactionListContext { public final class Item: Equatable { public let peer: EnginePeer public let reaction: MessageReaction.Reaction? public let timestamp: Int32? public let timestampIsReaction: Bool public init( peer: EnginePeer, reaction: MessageReaction.Reaction?, timestamp: Int32?, timestampIsReaction: Bool ) { self.peer = peer self.reaction = reaction self.timestamp = timestamp self.timestampIsReaction = timestampIsReaction } public static func ==(lhs: Item, rhs: Item) -> Bool { if lhs.peer != rhs.peer { return false } if lhs.reaction != rhs.reaction { return false } if lhs.timestamp != rhs.timestamp { return false } if lhs.timestampIsReaction != rhs.timestampIsReaction { return false } return true } } public struct State: Equatable { public var hasOutgoingReaction: Bool public var totalCount: Int public var items: [Item] public var canLoadMore: Bool public init( hasOutgoingReaction: Bool, totalCount: Int, items: [Item], canLoadMore: Bool ) { self.hasOutgoingReaction = hasOutgoingReaction self.totalCount = totalCount self.items = items self.canLoadMore = canLoadMore } } private final class Impl { struct InternalState: Equatable { var hasOutgoingReaction: Bool var totalCount: Int var items: [Item] var canLoadMore: Bool var nextOffset: String? } let queue: Queue let account: Account let message: EngineMessage let reaction: MessageReaction.Reaction? let disposable = MetaDisposable() var state: InternalState let statePromise = Promise() var isLoadingMore: Bool = false init(queue: Queue, account: Account, message: EngineMessage, readStats: MessageReadStats?, reaction: MessageReaction.Reaction?) { self.queue = queue self.account = account self.message = message self.reaction = reaction let initialState = EngineMessageReactionListContext.State(message: message, readStats: readStats, reaction: reaction) self.state = InternalState(hasOutgoingReaction: initialState.hasOutgoingReaction, totalCount: initialState.totalCount, items: initialState.items, canLoadMore: initialState.canLoadMore, nextOffset: nil) if initialState.canLoadMore { self.loadMore() } else { self.statePromise.set(.single(self.state)) } } deinit { assert(self.queue.isCurrent()) self.disposable.dispose() } func loadMore() { if self.isLoadingMore { return } self.isLoadingMore = true let account = self.account let accountPeerId = account.peerId let message = self.message let reaction = self.reaction let currentOffset = self.state.nextOffset let limit = self.state.items.isEmpty ? 50 : 100 let signal: Signal = self.account.postbox.transaction { transaction -> Api.InputPeer? in return transaction.getPeer(message.id.peerId).flatMap(apiInputPeer) } |> mapToSignal { inputPeer -> Signal in if message.id.namespace != Namespaces.Message.Cloud { return .single(InternalState(hasOutgoingReaction: false, totalCount: 0, items: [], canLoadMore: false, nextOffset: nil)) } guard let inputPeer = inputPeer else { return .single(InternalState(hasOutgoingReaction: false, totalCount: 0, items: [], canLoadMore: false, nextOffset: nil)) } var flags: Int32 = 0 if reaction != nil { flags |= 1 << 0 } if currentOffset != nil { flags |= 1 << 1 } return account.network.request(Api.functions.messages.getMessageReactionsList(flags: flags, peer: inputPeer, id: message.id.id, reaction: reaction?.apiReaction, offset: currentOffset, limit: Int32(limit))) |> map(Optional.init) |> `catch` { _ -> Signal in return .single(nil) } |> mapToSignal { result -> Signal in return account.postbox.transaction { transaction -> InternalState in switch result { case let .messageReactionsList(_, count, reactions, chats, users, nextOffset): let parsedPeers = AccumulatedPeers(transaction: transaction, chats: chats, users: users) updatePeers(transaction: transaction, accountPeerId: accountPeerId, peers: parsedPeers) var items: [EngineMessageReactionListContext.Item] = [] for reaction in reactions { switch reaction { case let .messagePeerReaction(_, peer, date, reaction): if let peer = transaction.getPeer(peer.peerId), let reaction = MessageReaction.Reaction(apiReaction: reaction) { items.append(EngineMessageReactionListContext.Item(peer: EnginePeer(peer), reaction: reaction, timestamp: date, timestampIsReaction: true)) } } } return InternalState(hasOutgoingReaction: false, totalCount: Int(count), items: items, canLoadMore: nextOffset != nil, nextOffset: nextOffset) case .none: return InternalState(hasOutgoingReaction: false, totalCount: 0, items: [], canLoadMore: false, nextOffset: nil) } } } } self.disposable.set((signal |> deliverOn(self.queue)).start(next: { [weak self] state in guard let strongSelf = self else { return } struct ItemHash: Hashable { var peerId: EnginePeer.Id var value: MessageReaction.Reaction? } var existingItems = Set() for item in strongSelf.state.items { existingItems.insert(ItemHash(peerId: item.peer.id, value: item.reaction)) } for item in state.items { let itemHash = ItemHash(peerId: item.peer.id, value: item.reaction) if existingItems.contains(itemHash) { continue } existingItems.insert(itemHash) strongSelf.state.items.append(item) } if state.canLoadMore { strongSelf.state.totalCount = max(state.totalCount, strongSelf.state.items.count) } else { strongSelf.state.totalCount = strongSelf.state.items.count } strongSelf.state.canLoadMore = state.canLoadMore strongSelf.state.nextOffset = state.nextOffset strongSelf.isLoadingMore = false strongSelf.statePromise.set(.single(strongSelf.state)) })) } } private let queue: Queue private let impl: QueueLocalObject public var state: Signal { return Signal { subscriber in let disposable = MetaDisposable() self.impl.with { impl in disposable.set(impl.statePromise.get().start(next: { state in subscriber.putNext(State( hasOutgoingReaction: state.hasOutgoingReaction, totalCount: state.totalCount, items: state.items, canLoadMore: state.canLoadMore )) })) } return disposable } } init(account: Account, message: EngineMessage, readStats: MessageReadStats?, reaction: MessageReaction.Reaction?) { let queue = Queue() self.queue = queue self.impl = QueueLocalObject(queue: queue, generate: { return Impl(queue: queue, account: account, message: message, readStats: readStats, reaction: reaction) }) } public func loadMore() { self.impl.with { impl in impl.loadMore() } } } public enum UpdatePeerAllowedReactionsError { case generic case boostRequired } func _internal_updatePeerReactionSettings(account: Account, peerId: PeerId, reactionSettings: PeerReactionSettings) -> Signal { return account.postbox.transaction { transaction -> Api.InputPeer? in return transaction.getPeer(peerId).flatMap(apiInputPeer) } |> castError(UpdatePeerAllowedReactionsError.self) |> mapToSignal { inputPeer -> Signal in guard let inputPeer = inputPeer else { return .fail(.generic) } var flags: Int32 = 0 let mappedReactions: Api.ChatReactions switch reactionSettings.allowedReactions { case .all: mappedReactions = .chatReactionsAll(flags: 0) case let .limited(array): mappedReactions = .chatReactionsSome(reactions: array.map(\.apiReaction)) case .empty: mappedReactions = .chatReactionsNone } var reactionLimitValue: Int32? if let maxReactionCount = reactionSettings.maxReactionCount { flags |= 1 << 0 reactionLimitValue = maxReactionCount } var paidEnabled: Api.Bool? if let starsAllowed = reactionSettings.starsAllowed { flags |= 1 << 1 paidEnabled = starsAllowed ? .boolTrue : .boolFalse } return account.network.request(Api.functions.messages.setChatAvailableReactions(flags: flags, peer: inputPeer, availableReactions: mappedReactions, reactionsLimit: reactionLimitValue, paidEnabled: paidEnabled)) |> map(Optional.init) |> `catch` { error -> Signal in if error.errorDescription == "CHAT_NOT_MODIFIED" { return .single(nil) } else if error.errorDescription == "BOOSTS_REQUIRED" { return .fail(.boostRequired) } else { return .fail(.generic) } } |> mapToSignal { result -> Signal in if let result = result { account.stateManager.addUpdates(result) } return account.postbox.transaction { transaction -> Void in transaction.updatePeerCachedData(peerIds: [peerId], update: { _, current in if let current = current as? CachedChannelData { return current.withUpdatedReactionSettings(.known(reactionSettings)) } else if let current = current as? CachedGroupData { return current.withUpdatedReactionSettings(.known(reactionSettings)) } else { return current } }) } |> ignoreValues |> castError(UpdatePeerAllowedReactionsError.self) } } } func _internal_updateDefaultReaction(account: Account, reaction: MessageReaction.Reaction) -> Signal { return account.network.request(Api.functions.messages.setDefaultReaction(reaction: reaction.apiReaction)) |> `catch` { _ -> Signal in return .single(.boolFalse) } |> ignoreValues } struct StarsReactionDefaultToPrivateData: Codable { private enum CodingKeys: String, CodingKey { case isPrivate = "isPrivate" case privacy = "p" } var privacy: TelegramPaidReactionPrivacy init(privacy: TelegramPaidReactionPrivacy) { self.privacy = privacy } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) if let privacy = try container.decodeIfPresent(TelegramPaidReactionPrivacy.self, forKey: .privacy) { self.privacy = privacy } else { self.privacy = try container.decode(Bool.self, forKey: .isPrivate) ? .anonymous : .default } } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.privacy, forKey: .privacy) } static func key() -> ValueBoxKey { let value = ValueBoxKey(length: 8) value.setInt64(0, value: 0) return value } } func _internal_getStarsReactionDefaultPrivacy(transaction: Transaction) -> TelegramPaidReactionPrivacy { guard let value = transaction.retrieveItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.starsReactionDefaultToPrivate, key: StarsReactionDefaultToPrivateData.key()))?.get(StarsReactionDefaultToPrivateData.self) else { return .default } return value.privacy } func _internal_setStarsReactionDefaultPrivacy(privacy: TelegramPaidReactionPrivacy, transaction: Transaction) { guard let entry = CodableEntry(StarsReactionDefaultToPrivateData(privacy: privacy)) else { return } transaction.putItemCacheEntry(id: ItemCacheEntryId(collectionId: Namespaces.CachedItemCollection.starsReactionDefaultToPrivate, key: StarsReactionDefaultToPrivateData.key()), entry: entry) }