This commit is contained in:
isaac 2026-04-28 17:00:21 +04:00
commit 1ae854e2e9
26 changed files with 432 additions and 128 deletions

View file

@ -2067,6 +2067,7 @@
"StickerPack.Share" = "Share";
"StickerPack.Send" = "Send Sticker";
"StickerPack.AddSticker" = "Add Sticker";
"StickerPack.RemoveStickerSet" = "Remove Sticker Set";
"StickerPack.RemoveStickerCount_1" = "Remove 1 Sticker";
"StickerPack.RemoveStickerCount_2" = "Remove 2 Stickers";
@ -16214,5 +16215,5 @@ Error: %8$@";
"Chat.AdminAction.ToastReactionsDeletedTitleSingle" = "Reaction Deleted";
"Chat.AdminAction.ToastReactionsDeletedTextSingle" = "Reaction Deleted.";
"Chat.AdminAction.ToastReactionsDeletedTextMultiple" = "Messages Deleted.";
"Chat.AdminAction.ToastReactionsDeletedTextMultiple" = "Reactions Deleted.";
"Chat.AdminAction.ToastMessagesAndReactionsDeletedText" = "Messages and reactions deleted.";

View file

@ -1117,6 +1117,7 @@ public protocol ChatController: ViewController {
func activateSearch(domain: ChatSearchDomain, query: String)
func activateInput(type: ChatControllerActivateInput)
func beginClearHistory(type: InteractiveHistoryClearingType)
func presentReactionDeletionOptions(author: Peer, messageId: MessageId)
func performScrollToTop() -> Bool
func transferScrollingVelocity(_ velocity: CGFloat)

View file

@ -2578,6 +2578,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode {
case poll
case todo
case game
case voiceMessage
}
var messageTypeIcon: MessageTypeIcon?
var ignoreForwardedIcon = false
@ -2895,7 +2896,11 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode {
messageTypeIcon = .story
} else {
for media in message.media {
if let _ = media as? TelegramMediaPoll {
if let file = media as? TelegramMediaFile {
if file.isVoice {
messageTypeIcon = .voiceMessage
}
} else if let _ = media as? TelegramMediaPoll {
messageTypeIcon = .poll
} else if let _ = media as? TelegramMediaTodo {
messageTypeIcon = .todo
@ -3106,6 +3111,9 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode {
case .game:
currentMessageTypeIcon = PresentationResourcesChatList.gameIcon(item.presentationData.theme)
currentMessageTypeIconOffset.y = -1.0
case .voiceMessage:
currentMessageTypeIcon = PresentationResourcesChatList.voiceMessageIcon(item.presentationData.theme)
currentMessageTypeIconOffset.y = -1.0
default:
break
}
@ -3115,7 +3123,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode {
if !contentImageSpecs.isEmpty {
textLeftCutout += forwardedIconSpacing
} else {
textLeftCutout += contentImageTrailingSpace
textLeftCutout += contentImageTrailingSpace - 1.0
}
}

View file

@ -1591,3 +1591,22 @@ public func canSendMessagesToChat(_ state: ChatPresentationInterfaceState) -> Bo
return false
}
}
public func canSendReactionsToChat(_ state: ChatPresentationInterfaceState) -> Bool {
if let peer = state.renderedPeer?.peer {
let canBypassRestrictions = canBypassRestrictions(chatPresentationInterfaceState: state)
return canSendReactionsToPeer(EnginePeer(peer), ignoreDefault: canBypassRestrictions)
} else if case .customChatContents = state.chatLocation {
if case let .customChatContents(contents) = state.subject {
if case .hashTagSearch = contents.kind {
return false
} else {
return true
}
} else {
return true
}
} else {
return false
}
}

View file

@ -94,7 +94,82 @@ func _internal_deleteAllReactionsWithAuthor(account: Account, peerId: PeerId, au
func _internal_deleteReaction(account: Account, messageId: MessageId, authorId: PeerId) -> Signal<Never, NoError> {
return account.postbox.transaction { transaction -> (Peer?, Peer?) in
return (transaction.getPeer(messageId.peerId), transaction.getPeer(authorId))
let peer = transaction.getPeer(messageId.peerId)
let author = transaction.getPeer(authorId)
if peer.flatMap(apiInputPeer) != nil && author.flatMap(apiInputPeer) != nil {
transaction.updateMessage(messageId, update: { currentMessage in
var attributes = currentMessage.attributes
var updated = false
for i in 0 ..< attributes.count {
guard let attribute = attributes[i] as? ReactionsMessageAttribute else {
continue
}
let removedRecentPeers = attribute.recentPeers.filter { $0.peerId == authorId }
var updatedTopPeers = attribute.topPeers
var removedStarsTopPeerCount: Int32 = 0
for j in (0 ..< updatedTopPeers.count).reversed() {
if updatedTopPeers[j].peerId == authorId {
removedStarsTopPeerCount += updatedTopPeers[j].count
updatedTopPeers.remove(at: j)
}
}
if removedRecentPeers.isEmpty && removedStarsTopPeerCount == 0 {
continue
}
var updatedReactions = attribute.reactions
for removedRecentPeer in removedRecentPeers {
if let index = updatedReactions.firstIndex(where: { $0.value == removedRecentPeer.value }) {
if removedRecentPeer.value != .stars || removedStarsTopPeerCount == 0 {
updatedReactions[index].count -= 1
}
if removedRecentPeer.isMy {
updatedReactions[index].chosenOrder = nil
}
}
}
if removedStarsTopPeerCount != 0, let index = updatedReactions.firstIndex(where: { $0.value == .stars }) {
updatedReactions[index].count -= removedStarsTopPeerCount
}
for j in (0 ..< updatedReactions.count).reversed() {
if updatedReactions[j].count <= 0 {
updatedReactions.remove(at: j)
}
}
let updatedRecentPeers = attribute.recentPeers.filter { $0.peerId != authorId }
let updatedAttribute = ReactionsMessageAttribute(canViewList: attribute.canViewList, isTags: attribute.isTags, reactions: updatedReactions, recentPeers: updatedRecentPeers, topPeers: updatedTopPeers)
if updatedAttribute != attribute {
attributes[i] = updatedAttribute
updated = true
}
}
if !updated {
return .skip
}
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 tags = currentMessage.tags
if attributes.contains(where: { ($0 as? ReactionsMessageAttribute)?.hasUnseen == true }) {
tags.insert(.unseenReaction)
} else {
tags.remove(.unseenReaction)
}
return .update(StoreMessage(id: currentMessage.id, customStableId: nil, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media))
})
}
return (peer, author)
}
|> mapToSignal { peer, author in
guard let inputPeer = peer.flatMap(apiInputPeer), let inputAuthor = author.flatMap(apiInputPeer) else {

View file

@ -19,3 +19,24 @@ public func canSendMessagesToPeer(_ peer: EnginePeer, ignoreDefault: Bool = fals
return channel.hasPermission(.sendSomething, ignoreDefault: ignoreDefault)
}
}
public func canSendReactionsToPeer(_ peer: EnginePeer, ignoreDefault: Bool = false) -> Bool {
switch peer {
case .user:
return !peer.isDeleted
case let .legacyGroup(group):
switch group.role {
case .creator, .admin:
return !peer.isDeleted
case .member:
if let defaultBannedRights = group.defaultBannedRights, defaultBannedRights.flags.contains(.banSendReactions) && !ignoreDefault {
return false
}
return !peer.isDeleted
}
case let .secretChat(secretChat):
return secretChat.embeddedState == .active
case let .channel(channel):
return channel.hasBannedPermission(.banSendReactions, ignoreDefault: ignoreDefault) == nil
}
}

View file

@ -138,6 +138,7 @@ public enum PresentationResourceKey: Int32 {
case chatListCallOutgoingIcon
case chatListCallVideoIncomingIcon
case chatListCallVideoOutgoingIcon
case chatListVoiceMessageIcon
case chatListGeneralTopicIcon
case chatListGeneralTopicTemplateIcon

View file

@ -349,6 +349,12 @@ public struct PresentationResourcesChatList {
})
}
public static func voiceMessageIcon(_ theme: PresentationTheme) -> UIImage? {
return theme.image(PresentationResourceKey.chatListVoiceMessageIcon.rawValue, { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat List/VoiceMessageIcon"), color: theme.chatList.muteIconColor)
})
}
public static func verifiedIcon(_ theme: PresentationTheme) -> UIImage? {
return theme.image(PresentationResourceKey.chatListVerifiedIcon.rawValue, { theme in
if let backgroundImage = UIImage(bundleImageName: "Chat List/PeerVerifiedIconBackground"), let foregroundImage = UIImage(bundleImageName: "Chat List/PeerVerifiedIconForeground") {

View file

@ -2367,6 +2367,8 @@ private let deletedMessagesDisplayedLimit = 4
func chatRecentActionsEntries(entries: [ChannelAdminEventLogEntry], presentationData: ChatPresentationData, expandedDeletedMessages: Set<EngineMessage.Id>, currentDeletedHeaderMessages: inout Set<EngineMessage.Id>) -> [ChatRecentActionsEntry] {
var result: [ChatRecentActionsEntry] = []
var deleteMessageEntries: [ChannelAdminEventLogEntry] = []
let previousDeletedHeaderMessages = currentDeletedHeaderMessages
currentDeletedHeaderMessages = Set()
func appendCurrentDeleteEntries() {
if !deleteMessageEntries.isEmpty, let lastEntry = deleteMessageEntries.last, let lastMessageId = lastEntry.event.action.messageId {
@ -2394,9 +2396,16 @@ func chatRecentActionsEntries(entries: [ChannelAdminEventLogEntry], presentation
var skipAppendingGeneralEntry = false
if case let .deleteMessage(message) = entry.event.action {
var skipAppendingDeletionEntry = false
if currentDeleteMessageEvent == nil || (currentDeleteMessageEvent!.peerId == entry.event.peerId && abs(currentDeleteMessageEvent!.date - entry.event.date) < 5 && !currentDeletedHeaderMessages.contains(message.id)) {
let belongsToCurrentDeleteGroup: Bool
if let currentDeleteMessageEvent {
belongsToCurrentDeleteGroup = currentDeleteMessageEvent.peerId == entry.event.peerId && abs(currentDeleteMessageEvent.date - entry.event.date) < 5
} else {
if currentDeletedHeaderMessages.contains(message.id) {
belongsToCurrentDeleteGroup = true
}
let wasPreviousHeaderMessage = previousDeletedHeaderMessages.contains(message.id)
if currentDeleteMessageEvent == nil || (belongsToCurrentDeleteGroup && !wasPreviousHeaderMessage) {
} else {
if belongsToCurrentDeleteGroup && wasPreviousHeaderMessage {
deleteMessageEntries.append(entry)
skipAppendingDeletionEntry = true
}

View file

@ -10,7 +10,7 @@ public enum AllowedReactions {
case all
}
public func peerMessageAllowedReactions(context: AccountContext, message: Message) -> Signal<(allowedReactions: AllowedReactions?, areStarsEnabled: Bool), NoError> {
public func peerMessageAllowedReactions(context: AccountContext, message: Message, ignoreDefault: Bool = false) -> Signal<(allowedReactions: AllowedReactions?, areStarsEnabled: Bool), NoError> {
if message.id.peerId == context.account.peerId {
return .single((.all, false))
}
@ -41,6 +41,10 @@ public func peerMessageAllowedReactions(context: AccountContext, message: Messag
areStarsEnabled = value
}
if let peer, !canSendReactionsToPeer(peer, ignoreDefault: ignoreDefault) {
return (nil, areStarsEnabled)
}
if let effectiveReactions = message.effectiveReactions(isTags: message.areReactionsTags(accountPeerId: context.account.peerId)), effectiveReactions.count >= maxReactionCount {
return (.set(Set(effectiveReactions.map(\.value))), areStarsEnabled)
}
@ -256,7 +260,7 @@ public func tagMessageReactions(context: AccountContext, subPeerId: EnginePeer.I
}
}
public func topMessageReactions(context: AccountContext, message: Message, subPeerId: EnginePeer.Id?) -> Signal<[ReactionItem], NoError> {
public func topMessageReactions(context: AccountContext, message: Message, subPeerId: EnginePeer.Id?, ignoreDefault: Bool = false) -> Signal<[ReactionItem], NoError> {
if message.id.peerId == context.account.peerId {
var loadTags = false
if let effectiveReactionsAttribute = message.effectiveReactionsAttribute(isTags: message.areReactionsTags(accountPeerId: context.account.peerId)) {
@ -286,7 +290,7 @@ public func topMessageReactions(context: AccountContext, message: Message, subPe
}
}
let allowedReactionsWithFiles: Signal<(reactions: AllowedReactions, files: [Int64: TelegramMediaFile], areStarsEnabled: Bool)?, NoError> = peerMessageAllowedReactions(context: context, message: message)
let allowedReactionsWithFiles: Signal<(reactions: AllowedReactions, files: [Int64: TelegramMediaFile], areStarsEnabled: Bool)?, NoError> = peerMessageAllowedReactions(context: context, message: message, ignoreDefault: ignoreDefault)
|> mapToSignal { allowedReactions, areStarsEnabled -> Signal<(reactions: AllowedReactions, files: [Int64: TelegramMediaFile], areStarsEnabled: Bool)?, NoError> in
guard let allowedReactions = allowedReactions else {
return .single(nil)

View file

@ -220,7 +220,7 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer
return self.contentNode.itemAt(point: CGPoint(x: point.x, y: point.y - searchBarHeight))
}
private func openSelectedPackMoreMenu() {
private func openMore() {
guard let selectedStickerPack = self.selectedStickerPack, let controlsView = self.navigationButtons.view as? GlassControlPanelComponent.View, let rightItemView = controlsView.rightItemView, let sourceView = rightItemView.itemView(id: AnyHashable("more")) else {
return
}
@ -272,6 +272,59 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer
}), nil)
})))
if selectedStickerPack.installed {
items.append(.separator)
items.append(.action(ContextMenuActionItem(text: strings.StickerPack_RemoveStickerSet, textColor: .destructive, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor)
}, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
let info = selectedStickerPack.info
let context = self.context
let _ = (context.engine.stickers.removeStickerPackInteractively(id: info.id, option: .delete)
|> deliverOnMainQueue).start(next: { [weak self] indexAndItems in
guard let self, let (positionInList, items) = indexAndItems else {
return
}
let stickerItems = items.compactMap { $0 as? StickerPackItem }
if let contentNode = self.contentNode as? StickerPaneSearchContentNode {
contentNode.setPackInstalledState(id: info.id, installed: false)
}
var animateInAsReplacement = false
if let navigationController = self.interaction.getNavigationController() {
for controller in navigationController.overlayControllers {
if let controller = controller as? UndoOverlayController {
controller.dismissWithCommitActionAndReplacementAnimation()
animateInAsReplacement = true
}
}
}
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: self.theme)
let undoController = UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_RemovedTitle, text: presentationData.strings.StickerPackActionInfo_RemovedText(info.title).string, undo: true, info: info, topItem: stickerItems.first, context: context), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { [weak self] action in
if case .undo = action {
let _ = context.engine.stickers.addStickerPackInteractively(info: info, items: stickerItems, positionInList: positionInList).start()
if let contentNode = self?.contentNode as? StickerPaneSearchContentNode {
contentNode.setPackInstalledState(id: info.id, installed: true)
}
}
return true
})
if let navigationController = self.interaction.getNavigationController() {
navigationController.presentOverlay(controller: undoController)
} else {
self.interaction.presentController(undoController, nil)
}
})
})))
}
let contextController = makeContextController(
presentationData: presentationData,
source: .reference(StickerPaneSearchHeaderContextReferenceContentSource(sourceView: sourceView)),
@ -327,7 +380,7 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer
id: AnyHashable("more"),
content: .animation("anim_morewide"),
action: { [weak self] in
self?.openSelectedPackMoreMenu()
self?.openMore()
}
)
],

View file

@ -82,6 +82,7 @@ private enum StickerSearchEntry: Identifiable, Comparable {
struct StickerPaneSearchSelectedPack {
let info: StickerPackCollectionInfo
let installed: Bool
}
private struct StickerPaneSearchPack: Equatable {
@ -497,19 +498,8 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode {
guard let strongSelf = self else {
return
}
var animateInAsReplacement = false
if let navigationController = strongSelf.interaction.getNavigationController() {
for controller in navigationController.overlayControllers {
if let controller = controller as? UndoOverlayController {
controller.dismissWithCommitActionAndReplacementAnimation()
animateInAsReplacement = true
}
}
}
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: theme)
strongSelf.interaction.getNavigationController()?.presentOverlay(controller: UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_AddedTitle, text: presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: strongSelf.context), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in
strongSelf.interaction.getNavigationController()?.presentOverlay(controller: UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_AddedTitle, text: presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: strongSelf.context), elevatedLayout: false, action: { _ in
return true
}))
}))
@ -688,7 +678,7 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode {
strongSelf.currentSearchEntries = entries
strongSelf.currentPacks = packItems
if let selectedPack = strongSelf.selectedPack, let updatedPack = packItems.first(where: { $0.info.id == selectedPack.info.id }), selectedPack.installed != updatedPack.installed {
strongSelf.selectedPack = StickerPaneSearchPack(info: selectedPack.info, topItems: selectedPack.topItems, installed: updatedPack.installed)
strongSelf.setPackInstalledState(id: selectedPack.info.id, installed: updatedPack.installed, transition: .immediate)
}
strongSelf.currentSearchIsFinal = final
strongSelf.searchIsActive = true
@ -796,20 +786,41 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode {
return !selectedPack.installed && !self.installedPackIds.contains(selectedPack.info.id)
}
private func markPackInstalled(_ id: ItemCollectionId) {
self.installedPackIds.insert(id)
if let selectedPack = self.selectedPack, selectedPack.info.id == id, !selectedPack.installed {
self.selectedPack = StickerPaneSearchPack(info: selectedPack.info, topItems: selectedPack.topItems, installed: true)
func setPackInstalledState(id: ItemCollectionId, installed: Bool, transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut)) {
if installed {
self.installedPackIds.insert(id)
} else {
self.installedPackIds.remove(id)
}
var updatedSelectedPack: StickerPaneSearchPack?
if let selectedPack = self.selectedPack, selectedPack.info.id == id {
if selectedPack.installed != installed {
let pack = StickerPaneSearchPack(info: selectedPack.info, topItems: selectedPack.topItems, installed: installed)
self.selectedPack = pack
updatedSelectedPack = pack
} else {
updatedSelectedPack = selectedPack
}
}
var updatedPacks = false
self.currentPacks = self.currentPacks.map { pack in
if pack.info.id == id && !pack.installed {
return StickerPaneSearchPack(info: pack.info, topItems: pack.topItems, installed: true)
if pack.info.id == id && pack.installed != installed {
updatedPacks = true
return StickerPaneSearchPack(info: pack.info, topItems: pack.topItems, installed: installed)
} else {
return pack
}
}
if let updatedSelectedPack {
self.selectedPackUpdated?(StickerPaneSearchSelectedPack(info: updatedSelectedPack.info, installed: updatedSelectedPack.installed))
}
if updatedSelectedPack != nil || updatedPacks {
self.requestLayout(transition: transition)
}
}
private func updatePackPanelExpansionFromScroll() {
@ -865,7 +876,7 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode {
self.notFoundNode.isHidden = true
self.gridNode.isHidden = false
self.trendingPane.isHidden = true
self.selectedPackUpdated?(StickerPaneSearchSelectedPack(info: pack.info))
self.selectedPackUpdated?(StickerPaneSearchSelectedPack(info: pack.info, installed: pack.installed))
self.enqueueEntries(self.entries(packItems: pack.topItems), interaction: interaction, crossfade: true)
self.isPackPanelExpanded = true
@ -896,8 +907,7 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode {
let packId = selectedPack.info.id
let accessHash = selectedPack.info.accessHash
self.markPackInstalled(packId)
self.requestLayout(transition: .animated(duration: 0.2, curve: .easeInOut))
self.setPackInstalledState(id: packId, installed: true)
let installSignal = (context.engine.stickers.loadedStickerPack(reference: .id(id: packId.id, accessHash: accessHash), forceActualized: false)
|> mapToSignal { result -> Signal<(StickerPackCollectionInfo, [StickerPackItem]), NoError> in
@ -931,19 +941,8 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode {
guard let self else {
return
}
var animateInAsReplacement = false
if let navigationController = self.interaction.getNavigationController() {
for controller in navigationController.overlayControllers {
if let controller = controller as? UndoOverlayController {
controller.dismissWithCommitActionAndReplacementAnimation()
animateInAsReplacement = true
}
}
}
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: self.theme)
self.interaction.getNavigationController()?.presentOverlay(controller: UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_AddedTitle, text: presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: self.context), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in
self.interaction.getNavigationController()?.presentOverlay(controller: UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_AddedTitle, text: presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: self.context), elevatedLayout: false, action: { _ in
return true
}))
}))

View file

@ -23,6 +23,7 @@ final class PeerInfoInteraction {
let openAddContact: () -> Void
let updateBlocked: (Bool) -> Void
let openReport: (PeerInfoReportType) -> Void
let openDeleteReaction: (MessageId) -> Void
let openShareBot: () -> Void
let openAddBotToGroup: () -> Void
let performBotCommand: (PeerInfoBotCommand) -> Void
@ -101,6 +102,7 @@ final class PeerInfoInteraction {
openAddContact: @escaping () -> Void,
updateBlocked: @escaping (Bool) -> Void,
openReport: @escaping (PeerInfoReportType) -> Void,
openDeleteReaction: @escaping (MessageId) -> Void,
openShareBot: @escaping () -> Void,
openAddBotToGroup: @escaping () -> Void,
performBotCommand: @escaping (PeerInfoBotCommand) -> Void,
@ -178,6 +180,7 @@ final class PeerInfoInteraction {
self.openAddContact = openAddContact
self.updateBlocked = updateBlocked
self.openReport = openReport
self.openDeleteReaction = openDeleteReaction
self.openShareBot = openShareBot
self.openAddBotToGroup = openAddBotToGroup
self.performBotCommand = performBotCommand

View file

@ -42,6 +42,7 @@ func infoItems(
presentationData: PresentationData,
interaction: PeerInfoInteraction,
reactionSourceMessageId: MessageId?,
canDeleteReaction: Bool,
callMessages: [Message],
chatLocation: ChatLocation,
isOpenedFromChat: Bool,
@ -91,9 +92,9 @@ func infoItems(
let ItemAffiliateInfo = 4001
let ItemBusinessHours = 5000
let ItemLocation = 5001
let ItemSendMessage = 6000
let ItemReport = 6001
let ItemAddToContacts = 6002
let ItemAddToContacts = 6000
let ItemDeleteReaction = 6001
let ItemReport = 6002
let ItemBlock = 6003
let ItemEncryptionKey = 6004
let ItemBalanceHeader = 7000
@ -369,23 +370,23 @@ func infoItems(
}
if !isMyProfile {
if let reactionSourceMessageId = reactionSourceMessageId, !data.isContact {
items[currentPeerInfoSection]!.append(PeerInfoScreenActionItem(id: ItemSendMessage, text: presentationData.strings.UserInfo_SendMessage, action: {
interaction.openChat(nil)
if !data.isContact, user.botInfo == nil {
items[currentPeerInfoSection]!.append(PeerInfoScreenActionItem(id: ItemAddToContacts, text: presentationData.strings.PeerInfo_AddToContacts, action: {
interaction.openAddContact()
}))
}
if let reactionSourceMessageId = reactionSourceMessageId {
if canDeleteReaction {
//TODO:localize
items[currentPeerInfoSection]!.append(PeerInfoScreenActionItem(id: ItemDeleteReaction, text: "Delete Reaction", color: .destructive, action: {
interaction.openDeleteReaction(reactionSourceMessageId)
}))
}
items[currentPeerInfoSection]!.append(PeerInfoScreenActionItem(id: ItemReport, text: presentationData.strings.ReportPeer_BanAndReport, color: .destructive, action: {
interaction.openReport(.reaction(reactionSourceMessageId))
}))
} else {
if !data.isContact {
if user.botInfo == nil {
items[currentPeerInfoSection]!.append(PeerInfoScreenActionItem(id: ItemAddToContacts, text: presentationData.strings.PeerInfo_AddToContacts, action: {
interaction.openAddContact()
}))
}
}
var isBlocked = false
if let cachedData = data.cachedData as? CachedUserData, cachedData.isBlocked {
isBlocked = true

View file

@ -283,6 +283,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro
var forceIsContactPromise = ValuePromise<Bool>(false)
let reactionSourceMessageId: MessageId?
let sourceMessageId: MessageId?
var canDeleteReaction = false
var dataDisposable: Disposable?
let activeActionDisposable = MetaDisposable()
@ -460,6 +461,9 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro
openReport: { [weak self] type in
self?.openReport(type: type, contextController: nil, backAction: nil)
},
openDeleteReaction: { [weak self] messageId in
self?.openDeleteReaction(messageId: messageId)
},
openShareBot: { [weak self] in
self?.openShareBot()
},
@ -2485,14 +2489,37 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro
self?.updateNavigation(transition: .immediate, additive: true, animateHeader: true)
}
let reactionSourceMessage: Signal<EngineMessage?, NoError>
if let reactionSourceMessageId = self.reactionSourceMessageId {
reactionSourceMessage = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Messages.Message(id: reactionSourceMessageId))
} else {
reactionSourceMessage = .single(nil)
}
self.dataDisposable = combineLatest(
queue: Queue.mainQueue(),
screenData,
self.forceIsContactPromise.get()
).startStrict(next: { [weak self] data, forceIsContact in
self.forceIsContactPromise.get(),
reactionSourceMessage
).startStrict(next: { [weak self] data, forceIsContact, reactionSourceMessage in
guard let strongSelf = self else {
return
}
var canDeleteReaction = false
if let sourceMessage = reactionSourceMessage?._asMessage(), let reactionPeerId = data.peer?.id, let channel = sourceMessage.peers[sourceMessage.id.peerId] as? TelegramChannel, channel.hasPermission(.deleteAllMessages) {
for attribute in sourceMessage.attributes {
guard let attribute = attribute as? ReactionsMessageAttribute else {
continue
}
if attribute.recentPeers.contains(where: { $0.peerId == reactionPeerId }) || attribute.topPeers.contains(where: { $0.peerId == reactionPeerId }) {
canDeleteReaction = true
break
}
}
}
strongSelf.canDeleteReaction = canDeleteReaction
if data.isContact && forceIsContact {
strongSelf.forceIsContactPromise.set(false)
} else {
@ -5414,7 +5441,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro
insets.left += sectionInset
insets.right += sectionInset
let items = self.isSettings ? settingsItems(data: self.data, context: self.context, presentationData: self.presentationData, interaction: self.interaction, isExpanded: self.headerNode.isAvatarExpanded) : infoItems(data: self.data, context: self.context, presentationData: self.presentationData, interaction: self.interaction, reactionSourceMessageId: self.reactionSourceMessageId, callMessages: self.callMessages, chatLocation: self.chatLocation, isOpenedFromChat: self.isOpenedFromChat, isMyProfile: self.isMyProfile)
let items = self.isSettings ? settingsItems(data: self.data, context: self.context, presentationData: self.presentationData, interaction: self.interaction, isExpanded: self.headerNode.isAvatarExpanded) : infoItems(data: self.data, context: self.context, presentationData: self.presentationData, interaction: self.interaction, reactionSourceMessageId: self.reactionSourceMessageId, canDeleteReaction: self.canDeleteReaction, callMessages: self.callMessages, chatLocation: self.chatLocation, isOpenedFromChat: self.isOpenedFromChat, isMyProfile: self.isMyProfile)
contentHeight += headerHeight
if !((self.isSettings || self.isMyProfile) && self.state.isEditing) {

View file

@ -81,6 +81,50 @@ extension PeerInfoScreenNode {
}
}
func openDeleteReaction(messageId: MessageId) {
guard let authorPeer = self.data?.peer?._asPeer(), let navigationController = self.controller?.navigationController as? NavigationController else {
return
}
let _ = (self.context.engine.data.get(
TelegramEngine.EngineData.Item.Peer.Peer(id: messageId.peerId),
TelegramEngine.EngineData.Item.Messages.Message(id: messageId)
)
|> deliverOnMainQueue).startStandalone(next: { [weak self] sourcePeer, sourceMessage in
guard let self, let sourcePeer, let sourceMessage = sourceMessage?._asMessage() else {
return
}
guard let channel = sourceMessage.peers[sourceMessage.id.peerId] as? TelegramChannel, channel.hasPermission(.deleteAllMessages) else {
return
}
var hasReaction = false
for attribute in sourceMessage.attributes {
guard let attribute = attribute as? ReactionsMessageAttribute else {
continue
}
if attribute.recentPeers.contains(where: { $0.peerId == authorPeer.id }) || attribute.topPeers.contains(where: { $0.peerId == authorPeer.id }) {
hasReaction = true
break
}
}
guard hasReaction else {
return
}
let chatLocation: NavigateToChatControllerParams.Location
if case let .channel(channel) = sourcePeer, channel.isForumOrMonoForum, let threadId = sourceMessage.threadId {
chatLocation = .replyThread(ChatReplyThreadMessage(peerId: sourcePeer.id, threadId: threadId, channelMessageId: nil, isChannelPost: false, isForumPost: true, isMonoforumPost: channel.isMonoForum, maxMessage: nil, maxReadIncomingMessageId: nil, maxReadOutgoingMessageId: nil, unreadCount: 0, initialFilledHoles: IndexSet(), initialAnchor: .automatic, isNotAvailable: false))
} else {
chatLocation = .peer(sourcePeer)
}
self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: chatLocation, subject: .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false), keepStack: .always, useExisting: true, completion: { chatController in
chatController.presentReactionDeletionOptions(author: authorPeer, messageId: messageId)
}))
})
}
func openChatWithClearedHistory(type: InteractiveHistoryClearingType) {
guard let peer = self.data?.chatPeer, let navigationController = self.controller?.navigationController as? NavigationController else {
return

View file

@ -496,19 +496,18 @@ func settingsEditingItems(data: PeerInfoScreenData?, state: PeerInfoState, conte
}
}
if "".isEmpty {
//TODO:localize
let automationBotTitle: String
if let botPeer = data.businessConnectedBot {
automationBotTitle = "@\(botPeer.compactDisplayTitle)"
} else {
automationBotTitle = "Off"
}
items[.info]!.append(PeerInfoScreenDisclosureItem(id: ItemPeerChatAutomation, label: .text(automationBotTitle), text: "Chat Automation", icon: PresentationResourcesSettings.aiTools, action: {
interaction.editingOpenBusinessChatBots()
}))
items[.info]!.append(PeerInfoScreenCommentItem(id: ItemPeerChatAutomationHelp, text: "Add a bot to reply to messages on your behalf."))
//TODO:localize
let automationBotTitle: String
if let botPeer = data.businessConnectedBot {
let _ = botPeer
automationBotTitle = "@\(botPeer.compactDisplayTitle)"
} else {
automationBotTitle = "Off"
}
items[.info]!.append(PeerInfoScreenDisclosureItem(id: ItemPeerChatAutomation, label: .text(automationBotTitle), additionalBadgeLabel: nil, text: "Chat Automation", icon: PresentationResourcesSettings.aiTools, action: {
interaction.editingOpenBusinessChatBots()
}))
items[.info]!.append(PeerInfoScreenCommentItem(id: ItemPeerChatAutomationHelp, text: "Add a bot to reply to messages on your behalf."))
items[.account]!.append(PeerInfoScreenActionItem(id: ItemAddAccount, text: presentationData.strings.Settings_AddAnotherAccount, alignment: .center, action: {
interaction.openSettings(.addAccount)

View file

@ -677,7 +677,11 @@ final class ChatbotSetupScreenComponent: Component {
}
transition.setPosition(view: self.titleTransformContainer, position: navigationTitleFrame.center)
transition.setBounds(view: self.titleTransformContainer, bounds: CGRect(origin: CGPoint(), size: navigationTitleFrame.size))
transition.setFrame(view: navigationTitleView, frame: CGRect(origin: CGPoint(), size: navigationTitleFrame.size))
transition.setBounds(view: navigationTitleView, bounds: CGRect(origin: CGPoint(), size: navigationTitleFrame.size))
transition.setPosition(view: navigationTitleView, position: CGPoint(
x: navigationTitleFrame.size.width * 0.5,
y: navigationTitleFrame.size.height * 0.5
))
}
let bottomContentInset: CGFloat = 24.0

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "voice_message_20.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -18,6 +18,7 @@ import TooltipUI
import TopMessageReactions
import TelegramNotices
import PresentationDataUtils
import ChatPresentationInterfaceState
extension ChatControllerImpl {
func openMessageContextMenu(message: Message, selectAll: Bool, node: ASDisplayNode, frame: CGRect, anyRecognizer: UIGestureRecognizer?, location: CGPoint?) -> Void {
@ -45,13 +46,15 @@ extension ChatControllerImpl {
guard let topMessage = messages.first else {
return
}
let canBypassReactionRestrictions = canBypassRestrictions(chatPresentationInterfaceState: self.presentationInterfaceState)
let _ = combineLatest(queue: .mainQueue(),
self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId)),
contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState: self.presentationInterfaceState, context: self.context, messages: updatedMessages, controllerInteraction: self.controllerInteraction, selectAll: selectAll, interfaceInteraction: self.interfaceInteraction, messageNode: node as? ChatMessageItemView),
peerMessageAllowedReactions(context: self.context, message: topMessage),
peerMessageAllowedReactions(context: self.context, message: topMessage, ignoreDefault: canBypassReactionRestrictions),
peerMessageSelectedReactions(context: self.context, message: topMessage),
topMessageReactions(context: self.context, message: topMessage, subPeerId: self.chatLocation.threadId.flatMap(EnginePeer.Id.init)),
topMessageReactions(context: self.context, message: topMessage, subPeerId: self.chatLocation.threadId.flatMap(EnginePeer.Id.init), ignoreDefault: canBypassReactionRestrictions),
ApplicationSpecificNotice.getChatTextSelectionTips(accountManager: self.context.sharedContext.accountManager)
).startStandalone(next: { [weak self] peer, actions, allowedReactionsAndStars, selectedReactions, topReactions, chatTextSelectionTips in
guard let self else {
@ -370,6 +373,11 @@ extension ChatControllerImpl {
controller?.view.endEditing(true)
if case .stars = chosenUpdatedReaction.reaction {
if !canSendReactionsToChat(self.presentationInterfaceState) {
controller?.dismiss(completion: {})
return
}
if isLarge {
if let controller {
controller.dismiss(completion: { [weak self] in
@ -505,6 +513,11 @@ extension ChatControllerImpl {
isFirst = !currentReactions.contains(where: { $0.value == chosenReaction })
}
if removedReaction == nil && !canSendReactionsToChat(self.presentationInterfaceState) {
controller?.dismiss(completion: {})
return
}
if message.areReactionsTags(accountPeerId: self.context.account.peerId) {
if removedReaction == nil, !topReactions.contains(where: { $0.reaction.rawValue == chosenReaction }) {
if !self.presentationInterfaceState.isPremium {

View file

@ -1679,6 +1679,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
if case .default = reaction, strongSelf.chatLocation.peerId == strongSelf.context.account.peerId {
return
}
if case .default = reaction, !canSendReactionsToChat(strongSelf.presentationInterfaceState) {
return
}
if case let .customChatContents(customChatContents) = strongSelf.presentationInterfaceState.subject {
if case let .hashTagSearch(publicPosts) = customChatContents.kind, publicPosts {
return
@ -1747,7 +1750,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
return
}
let _ = (peerMessageAllowedReactions(context: strongSelf.context, message: message)
let _ = (peerMessageAllowedReactions(context: strongSelf.context, message: message, ignoreDefault: canBypassRestrictions(chatPresentationInterfaceState: strongSelf.presentationInterfaceState))
|> deliverOnMainQueue).startStandalone(next: { allowedReactions, _ in
guard let strongSelf = self else {
return
@ -1791,6 +1794,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
}
if case .stars = chosenReaction {
if !canSendReactionsToChat(strongSelf.presentationInterfaceState) {
return
}
if strongSelf.selectPollOptionFeedback == nil {
strongSelf.selectPollOptionFeedback = HapticFeedback()
}
@ -1957,6 +1964,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
}
if removedReaction == nil {
if !canSendReactionsToChat(strongSelf.presentationInterfaceState) {
return
}
if !canAddMessageReactions(message: message) {
itemNode.openMessageContextMenu()
return

View file

@ -261,6 +261,30 @@ extension ChatControllerImpl {
}))
}
public func presentReactionDeletionOptions(author: Peer, messageId: MessageId) {
guard self.chatLocation.peerId?.namespace == Namespaces.Peer.CloudChannel else {
return
}
let _ = (self.context.sharedContext.chatAvailableMessageActions(
engine: self.context.engine,
accountPeerId: self.context.account.peerId,
messageIds: Set([messageId]),
keepUpdated: false
)
|> deliverOnMainQueue).startStandalone(next: { [weak self] actions in
guard let self, !actions.options.isEmpty else {
return
}
self.presentBanMessageOptions(
accountPeerId: self.context.account.peerId,
author: author,
messageIds: Set([messageId]),
options: actions.options,
reaction: true
)
})
}
func presentBanMessageOptions(accountPeerId: PeerId, author: Peer, messageIds: Set<MessageId>, options: ChatAvailableMessageActionOptions, reaction: Bool = false) {
guard let peerId = self.chatLocation.peerId else {
return

View file

@ -188,33 +188,21 @@ extension ChatControllerImpl {
}
var dismissController: ((@escaping () -> Void) -> Void)?
let canDeleteReactions = canDeleteOtherUserMessages(message: message)
let canDeleteReactions: Bool
if let channel = message.peers[message.id.peerId] as? TelegramChannel {
canDeleteReactions = channel.hasPermission(.deleteAllMessages)
} else {
canDeleteReactions = false
}
let canViewReactions = canViewMessageReactionList(message: message)
let deleteReaction: ((EnginePeer, MessageReaction.Reaction) -> Void)?
if canDeleteReactions && canViewReactions {
deleteReaction = { [weak self] peer, _ in
dismissController?({ [weak self] in
guard let self, self.chatLocation.peerId?.namespace == Namespaces.Peer.CloudChannel else {
guard let self else {
return
}
let _ = (self.context.sharedContext.chatAvailableMessageActions(
engine: self.context.engine,
accountPeerId: self.context.account.peerId,
messageIds: Set([message.id]),
keepUpdated: false
)
|> deliverOnMainQueue).startStandalone(next: { [weak self] actions in
guard let self, !actions.options.isEmpty else {
return
}
self.presentBanMessageOptions(
accountPeerId: self.context.account.peerId,
author: peer._asPeer(),
messageIds: Set([message.id]),
options: actions.options,
reaction: true
)
})
self.presentReactionDeletionOptions(author: peer._asPeer(), messageId: message.id)
})
}
} else {
@ -408,6 +396,10 @@ extension ChatControllerImpl {
}
func openMessageSendStarsScreen(message: Message) {
guard canSendReactionsToChat(self.presentationInterfaceState) else {
return
}
if let current = self.currentSendStarsUndoController {
self.currentSendStarsUndoController = nil
current.dismiss()

View file

@ -52,13 +52,6 @@ func canEditMessage(context: AccountContext, limitsConfiguration: EngineConfigur
return canEditMessage(accountPeerId: context.account.peerId, limitsConfiguration: limitsConfiguration, message: message)
}
func canDeleteOtherUserMessages(message: Message) -> Bool {
guard let channel = message.peers[message.id.peerId] as? TelegramChannel else {
return false
}
return channel.hasPermission(.deleteAllMessages)
}
private func canEditMessage(accountPeerId: PeerId, limitsConfiguration: EngineConfiguration.Limits, message: Message, reschedule: Bool = false) -> Bool {
var hasEditRights = false
var unlimitedInterval = reschedule
@ -2181,30 +2174,13 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
}
let deleteReaction: ((EnginePeer, MessageReaction.Reaction) -> Void)?
if canDeleteOtherUserMessages(message: message) {
if let channel = message.peers[message.id.peerId] as? TelegramChannel, channel.hasPermission(.deleteAllMessages) {
deleteReaction = { [weak c] peer, _ in
c?.dismiss(completion: {
guard let chatController = interfaceInteraction.chatController() as? ChatControllerImpl, chatController.chatLocation.peerId?.namespace == Namespaces.Peer.CloudChannel else {
guard let chatController = interfaceInteraction.chatController() as? ChatController else {
return
}
let _ = (context.sharedContext.chatAvailableMessageActions(
engine: context.engine,
accountPeerId: context.account.peerId,
messageIds: Set([message.id]),
keepUpdated: false
)
|> deliverOnMainQueue).startStandalone(next: { actions in
guard !actions.options.isEmpty else {
return
}
chatController.presentBanMessageOptions(
accountPeerId: context.account.peerId,
author: peer._asPeer(),
messageIds: Set([message.id]),
options: actions.options,
reaction: true
)
})
chatController.presentReactionDeletionOptions(author: peer._asPeer(), messageId: message.id)
})
}
} else {

View file

@ -182,6 +182,7 @@ public func navigateToChatControllerImpl(_ params: NavigateToChatControllerParam
controller.navigateToMessage(messageLocation: .id(messageId, NavigateToMessageParams(timestamp: timecode, quote: (highlight?.quote).flatMap { quote in NavigateToMessageParams.Quote(string: quote.string, offset: quote.offset) }, subject: highlight?.subject, setupReply: setupReply)), animated: isFirst || params.forceAnimatedScroll, completion: { [weak navigationController, weak controller] in
if let navigationController = navigationController, let controller = controller {
let _ = navigationController.popToViewController(controller, animated: animated)
params.completion(controller)
}
}, customPresentProgress: { [weak navigationController] c, a in
(navigationController?.viewControllers.last as? ViewController)?.present(c, in: .window(.root), with: a)