From a68d6f31c1b17db1a3bd8b0ab507c36f6caa759f Mon Sep 17 00:00:00 2001 From: Peter Date: Fri, 7 Oct 2016 19:14:56 +0300 Subject: [PATCH] no message --- .../PeerMutedIcon.imageset/Contents.json | 21 + .../DialogList_Muted@2x.png | Bin 0 -> 208 bytes .../Checkmark.imageset/Contents.json | 21 + .../Checkmark.imageset/ModernMenuCheck@2x.png | Bin 0 -> 239 bytes Images.xcassets/List Menu/Contents.json | 9 + .../Contents.json | 22 + .../SharedMediaDocumentStatusDownload@2x.png | Bin 0 -> 165 bytes .../SharedMediaDocumentStatusDownload@3x.png | Bin 0 -> 325 bytes Images.xcassets/Media Grid/Contents.json | 9 + .../Contents.json | 22 + .../SharedMediaNavigationBarArrow@2x.png | Bin 0 -> 128 bytes .../SharedMediaNavigationBarArrow@3x.png | Bin 0 -> 378 bytes Images.xcassets/Peer Info/Contents.json | 9 + .../DisclosureArrow.imageset/Contents.json | 21 + ...ModernListsDisclosureIndicatorSmall@2x.png | Bin 0 -> 248 bytes TelegramUI.xcodeproj/project.pbxproj | 192 +++++ TelegramUI/AvatarNode.swift | 2 +- .../ChannelBroadcastInfoController.swift | 333 ++++++++ TelegramUI/ChatController.swift | 792 +++--------------- TelegramUI/ChatControllerInteraction.swift | 2 +- TelegramUI/ChatControllerNode.swift | 53 +- TelegramUI/ChatDocumentGalleryItem.swift | 59 ++ TelegramUI/ChatHistoryEntriesForView.swift | 35 + TelegramUI/ChatHistoryEntry.swift | 28 +- TelegramUI/ChatHistoryGridNode.swift | 305 +++++++ TelegramUI/ChatHistoryListNode.swift | 402 +++++++++ TelegramUI/ChatHistoryNode.swift | 13 + TelegramUI/ChatHistoryViewForLocation.swift | 109 +++ TelegramUI/ChatHoleItem.swift | 2 +- TelegramUI/ChatImageGalleryItem.swift | 11 - TelegramUI/ChatInfo.swift | 24 + TelegramUI/ChatListController.swift | 61 +- TelegramUI/ChatListItem.swift | 101 ++- TelegramUI/ChatListSearchContainerNode.swift | 2 +- TelegramUI/ChatMediaActionSheetRollItem.swift | 3 +- TelegramUI/ChatMessageActionItemNode.swift | 2 +- TelegramUI/ChatMessageBubbleContentNode.swift | 2 +- TelegramUI/ChatMessageBubbleItemNode.swift | 13 +- TelegramUI/ChatMessageDateAndStatusNode.swift | 31 +- .../ChatMessageFileBubbleContentNode.swift | 4 +- .../ChatMessageInteractiveMediaNode.swift | 2 +- TelegramUI/ChatMessageItem.swift | 4 +- .../ChatMessageMediaBubbleContentNode.swift | 4 +- .../ChatMessageTextBubbleContentNode.swift | 14 +- .../ChatMessageWebpageBubbleContentNode.swift | 13 +- TelegramUI/ChatTitleView.swift | 86 ++ TelegramUI/ChatVideoGalleryItem.swift | 4 - TelegramUI/ContactsPeerItem.swift | 2 +- TelegramUI/ContactsVCardItem.swift | 2 +- TelegramUI/GalleryController.swift | 75 +- TelegramUI/GalleryControllerNode.swift | 30 +- TelegramUI/GridHoleItem.swift | 30 + TelegramUI/GridMessageItem.swift | 160 ++++ TelegramUI/GridMessageSelectionNode.swift | 64 ++ TelegramUI/ListController.swift | 4 +- TelegramUI/ListControllerButtonItem.swift | 2 +- .../ListControllerDisclosureActionItem.swift | 2 +- TelegramUI/ListControllerNode.swift | 14 +- TelegramUI/ListMessageFileItemNode.swift | 531 ++++++++++++ TelegramUI/ListMessageItem.swift | 98 +++ TelegramUI/ListMessageNode.swift | 38 + TelegramUI/ListMessageSnippetItemNode.swift | 270 ++++++ TelegramUI/PeerInfoActionItem.swift | 141 ++++ TelegramUI/PeerInfoAvatarAndNameItem.swift | 142 ++++ TelegramUI/PeerInfoDisclosureItem.swift | 157 ++++ TelegramUI/PeerInfoItem.swift | 18 + TelegramUI/PeerInfoTextWithLabelItem.swift | 117 +++ .../PeerMediaCollectionController.swift | 303 +++++++ .../PeerMediaCollectionControllerNode.swift | 217 +++++ .../PeerMediaCollectionInterfaceState.swift | 109 +++ ...MediaCollectionInterfaceStateButtons.swift | 31 + ...PeerMediaCollectionModeSelectionNode.swift | 188 +++++ TelegramUI/PeerMediaCollectionTitleView.swift | 96 +++ TelegramUI/PhotoResources.swift | 105 ++- .../PreparedChatHistoryViewTransition.swift | 177 ++++ TelegramUI/RadialProgressNode.swift | 4 +- TelegramUI/SettingsAccountInfoItem.swift | 2 +- TelegramUI/TextNode.swift | 2 +- TelegramUI/UserInfoController.swift | 386 +++++++++ 79 files changed, 5487 insertions(+), 872 deletions(-) create mode 100644 Images.xcassets/Chat List/PeerMutedIcon.imageset/Contents.json create mode 100644 Images.xcassets/Chat List/PeerMutedIcon.imageset/DialogList_Muted@2x.png create mode 100644 Images.xcassets/List Menu/Checkmark.imageset/Contents.json create mode 100644 Images.xcassets/List Menu/Checkmark.imageset/ModernMenuCheck@2x.png create mode 100644 Images.xcassets/List Menu/Contents.json create mode 100644 Images.xcassets/List Menu/ListDownloadStartIcon.imageset/Contents.json create mode 100644 Images.xcassets/List Menu/ListDownloadStartIcon.imageset/SharedMediaDocumentStatusDownload@2x.png create mode 100644 Images.xcassets/List Menu/ListDownloadStartIcon.imageset/SharedMediaDocumentStatusDownload@3x.png create mode 100644 Images.xcassets/Media Grid/Contents.json create mode 100644 Images.xcassets/Media Grid/TitleViewModeSelectionArrow.imageset/Contents.json create mode 100644 Images.xcassets/Media Grid/TitleViewModeSelectionArrow.imageset/SharedMediaNavigationBarArrow@2x.png create mode 100644 Images.xcassets/Media Grid/TitleViewModeSelectionArrow.imageset/SharedMediaNavigationBarArrow@3x.png create mode 100644 Images.xcassets/Peer Info/Contents.json create mode 100644 Images.xcassets/Peer Info/DisclosureArrow.imageset/Contents.json create mode 100644 Images.xcassets/Peer Info/DisclosureArrow.imageset/ModernListsDisclosureIndicatorSmall@2x.png create mode 100644 TelegramUI/ChannelBroadcastInfoController.swift create mode 100644 TelegramUI/ChatHistoryEntriesForView.swift create mode 100644 TelegramUI/ChatHistoryGridNode.swift create mode 100644 TelegramUI/ChatHistoryListNode.swift create mode 100644 TelegramUI/ChatHistoryNode.swift create mode 100644 TelegramUI/ChatHistoryViewForLocation.swift create mode 100644 TelegramUI/ChatInfo.swift create mode 100644 TelegramUI/ChatTitleView.swift create mode 100644 TelegramUI/GridHoleItem.swift create mode 100644 TelegramUI/GridMessageItem.swift create mode 100644 TelegramUI/GridMessageSelectionNode.swift create mode 100644 TelegramUI/ListMessageFileItemNode.swift create mode 100644 TelegramUI/ListMessageItem.swift create mode 100644 TelegramUI/ListMessageNode.swift create mode 100644 TelegramUI/ListMessageSnippetItemNode.swift create mode 100644 TelegramUI/PeerInfoActionItem.swift create mode 100644 TelegramUI/PeerInfoAvatarAndNameItem.swift create mode 100644 TelegramUI/PeerInfoDisclosureItem.swift create mode 100644 TelegramUI/PeerInfoItem.swift create mode 100644 TelegramUI/PeerInfoTextWithLabelItem.swift create mode 100644 TelegramUI/PeerMediaCollectionController.swift create mode 100644 TelegramUI/PeerMediaCollectionControllerNode.swift create mode 100644 TelegramUI/PeerMediaCollectionInterfaceState.swift create mode 100644 TelegramUI/PeerMediaCollectionInterfaceStateButtons.swift create mode 100644 TelegramUI/PeerMediaCollectionModeSelectionNode.swift create mode 100644 TelegramUI/PeerMediaCollectionTitleView.swift create mode 100644 TelegramUI/PreparedChatHistoryViewTransition.swift create mode 100644 TelegramUI/UserInfoController.swift diff --git a/Images.xcassets/Chat List/PeerMutedIcon.imageset/Contents.json b/Images.xcassets/Chat List/PeerMutedIcon.imageset/Contents.json new file mode 100644 index 0000000000..0300ffdc7f --- /dev/null +++ b/Images.xcassets/Chat List/PeerMutedIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "DialogList_Muted@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Chat List/PeerMutedIcon.imageset/DialogList_Muted@2x.png b/Images.xcassets/Chat List/PeerMutedIcon.imageset/DialogList_Muted@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..06f297b781957d1b4773242e1ca2601212dabe76 GIT binary patch literal 208 zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqEBNeutY4NNL8^S!d@G~E_u~a{;Q+l zT9yfcb$@ui^)J3=RH0w>(U<-Fr|aQv>LF4s`w~>IFqQ9QJ6q$}#_k4mB7>)^pUXO@ GgeCy1I7}A+ literal 0 HcmV?d00001 diff --git a/Images.xcassets/List Menu/Checkmark.imageset/Contents.json b/Images.xcassets/List Menu/Checkmark.imageset/Contents.json new file mode 100644 index 0000000000..d0e3bfd1e3 --- /dev/null +++ b/Images.xcassets/List Menu/Checkmark.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ModernMenuCheck@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/List Menu/Checkmark.imageset/ModernMenuCheck@2x.png b/Images.xcassets/List Menu/Checkmark.imageset/ModernMenuCheck@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..79f67a97c8a347fb88b7280cb131eef44582e7a3 GIT binary patch literal 239 zcmVk$#JT{p#rQlLM*{$UGA*=zi;M*_k9spZ+tCKZK&d|Ivkd#dQ<(V z_Aa7;iEX^Z-c)5bu$&53@e-@Lfn`IBWmK@5-Y`lvrApewK1Q(>H+TT5wn>}8aJ+ya zqUJFjuub)a2X{qud`+ofbt4p_KXp_U`vT?&ns5?Bw4;K)9LID}#2QXeh#uU=5FJvT p0Bbr)AQ+?Sd4VC?psGhmy+4FEk{4=YS!MtL002ovPDHLkV1n1oU&H_a literal 0 HcmV?d00001 diff --git a/Images.xcassets/List Menu/Contents.json b/Images.xcassets/List Menu/Contents.json new file mode 100644 index 0000000000..38f0c81fc2 --- /dev/null +++ b/Images.xcassets/List Menu/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "provides-namespace" : true + } +} \ No newline at end of file diff --git a/Images.xcassets/List Menu/ListDownloadStartIcon.imageset/Contents.json b/Images.xcassets/List Menu/ListDownloadStartIcon.imageset/Contents.json new file mode 100644 index 0000000000..5bd2c8cf52 --- /dev/null +++ b/Images.xcassets/List Menu/ListDownloadStartIcon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "SharedMediaDocumentStatusDownload@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "SharedMediaDocumentStatusDownload@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/List Menu/ListDownloadStartIcon.imageset/SharedMediaDocumentStatusDownload@2x.png b/Images.xcassets/List Menu/ListDownloadStartIcon.imageset/SharedMediaDocumentStatusDownload@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..4d71988c91103fe282f7d5eafea33e86eed0d491 GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H1|*Mc$*~4fIi4<#Ar*{oFI{JBP~c$+C@kF6 zvDsqA-txV3_O|k!{Cy~}wZ+r8W2aC OW$<+Mb6Mw<&;$UlHa#c+ literal 0 HcmV?d00001 diff --git a/Images.xcassets/List Menu/ListDownloadStartIcon.imageset/SharedMediaDocumentStatusDownload@3x.png b/Images.xcassets/List Menu/ListDownloadStartIcon.imageset/SharedMediaDocumentStatusDownload@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..2359970b0783e23e8abbce5d4be89570e393f5f2 GIT binary patch literal 325 zcmV-L0lNN)P)k zCm_$W1ZJg!1};LZCO6+q27oKxaJGc#DNVOFaKiG-?DI;wSKb>+XC}>!<1+ X#_VA*m!1_;00000NkvXXu0mjf!vz{?WzPAnUK<}D?^+tJ zgV2v$7x&3QKZ1V$5rRo5Jfgrw0~Fu^3X=%^_#*;KQ26kKQiBI5#nZ1o^ripD7SL!k zXn+QN5Bl?xh8rQM4ZaRG_eGmf`xa>*!DgHZ!QJq)a{xbd2{q4P{+(pPiICKf02>Dg zK)+Dy1SVZ_GWLYzQ3Tm=s*eaY_K;$2E#j~x6q!SqCN~UoLhcsgY~7!cSQCnEAo^EM z6gGq$>Jg(`gteL$itZr(JvJUYLhLlcoVk<_3dIkQg##gG2tm$XX#CMDG`Sc4BaeK8 zu6VxafU+I&^aA YXZJiz-f+)+DkzI(g Bool { + switch lhs { + case let .info(lhsPeer, lhsCachedData): + switch rhs { + case let .info(rhsPeer, rhsCachedData): + if let lhsPeer = lhsPeer, let rhsPeer = rhsPeer { + if !lhsPeer.isEqual(rhsPeer) { + return false + } + } else if (lhsPeer == nil) != (rhsPeer != nil) { + return false + } + if let lhsCachedData = lhsCachedData, let rhsCachedData = rhsCachedData { + if !lhsCachedData.isEqual(to: rhsCachedData) { + return false + } + } else if (rhsCachedData == nil) != (rhsCachedData != nil) { + return false + } + return true + default: + return false + } + case let .about(lhsText): + switch rhs { + case let .about(lhsText): + return true + default: + return false + } + case let .userName(value): + switch rhs { + case .userName(value): + return true + default: + return false + } + case .sharedMedia: + switch rhs { + case .sharedMedia: + return true + default: + return false + } + case let .notifications(lhsSettings): + switch rhs { + case let .notifications(rhsSettings): + if let lhsSettings = lhsSettings, let rhsSettings = rhsSettings { + return lhsSettings.isEqual(to: rhsSettings) + } else if (lhsSettings != nil) != (rhsSettings != nil) { + return false + } + return true + default: + return false + } + case .report: + switch rhs { + case .report: + return true + default: + return false + } + case .leave: + switch rhs { + case .leave: + return true + default: + return false + } + } + } + + private var sortIndex: Int { + switch self { + case .info: + return 0 + case .about: + return 1 + case .userName: + return 1000 + case .sharedMedia: + return 1004 + case .notifications: + return 1005 + case .report: + return 1006 + case .leave: + return 1007 + } + } + + fileprivate static func <(lhs: ChannelInfoEntry, rhs: ChannelInfoEntry) -> Bool { + return lhs.sortIndex < rhs.sortIndex + } +} + +private func channelBroadcastInfoEntries(account: Account, peerId: PeerId) -> Signal<[ChannelInfoEntry], NoError> { + return account.viewTracker.peerView(peerId) + |> map { view -> [ChannelInfoEntry] in + var entries: [ChannelInfoEntry] = [] + entries.append(.info(peer: view.peers[peerId], cachedData: view.cachedData)) + if let cachedChannelData = view.cachedData as? CachedChannelData { + if let about = cachedChannelData.about, !about.isEmpty { + entries.append(.about(text: about)) + } + } + if let channel = view.peers[peerId] as? TelegramChannel { + if let username = channel.username, !username.isEmpty { + entries.append(.userName(value: username)) + } + entries.append(.sharedMedia) + entries.append(.notifications(settings: view.notificationSettings)) + entries.append(.report) + if channel.participationStatus == .member { + entries.append(.leave) + } + } + return entries + } +} + +private struct ChannelInfoEntryTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] +} + +private func infoItemForEntry(account: Account, entry: ChannelInfoEntry, interaction: PeerInfoControllerInteraction) -> ListViewItem { + switch entry { + case let .info(peer, cachedData): + return PeerInfoAvatarAndNameItem(account: account, peer: peer, cachedData: cachedData, sectionId: entry.section.rawValue) + case let .about(text): + return PeerInfoTextWithLabelItem(label: "about", text: text, multiline: true, sectionId: entry.section.rawValue) + case let .userName(value): + return PeerInfoTextWithLabelItem(label: "share link", text: "https://telegram.me/\(value)", multiline: false, sectionId: entry.section.rawValue) + return PeerInfoActionItem(title: "Start Secret Chat", kind: .generic, sectionId: entry.section.rawValue, action: { + + }) + case .sharedMedia: + return PeerInfoDisclosureItem(title: "Shared Media", label: "", sectionId: entry.section.rawValue, action: { + interaction.openSharedMedia() + }) + case let .notifications(settings): + let label: String + if let settings = settings as? TelegramPeerNotificationSettings, case .muted = settings.muteState { + label = "Disabled" + } else { + label = "Enabled" + } + return PeerInfoDisclosureItem(title: "Notifications", label: label, sectionId: entry.section.rawValue, action: { + interaction.changeNotificationNoteSettings() + }) + case .report: + return PeerInfoActionItem(title: "Report", kind: .generic, sectionId: entry.section.rawValue, action: { + + }) + case .leave: + return PeerInfoActionItem(title: "Leave Channel", kind: .destructive, sectionId: entry.section.rawValue, action: { + + }) + } +} + +private func preparedUserInfoEntryTransition(account: Account, from fromEntries: [ChannelInfoEntry], to toEntries: [ChannelInfoEntry], interaction: PeerInfoControllerInteraction) -> ChannelInfoEntryTransition { + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) + + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: infoItemForEntry(account: account, entry: $0.1, interaction: interaction), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: infoItemForEntry(account: account, entry: $0.1, interaction: interaction), directionHint: nil) } + + return ChannelInfoEntryTransition(deletions: deletions, insertions: insertions, updates: updates) +} + +public class ChannelBroadcastInfoController: ListController { + private let account: Account + private let peerId: PeerId + + private var _ready = Promise() + override public var ready: Promise { + return self._ready + } + private var didSetReady = false + + private let transitionDisposable = MetaDisposable() + private let changeSettingsDisposable = MetaDisposable() + + public init(account: Account, peerId: PeerId) { + self.account = account + self.peerId = peerId + + super.init() + + self.title = "Info" + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.transitionDisposable.dispose() + self.changeSettingsDisposable.dispose() + } + + override public func displayNodeDidLoad() { + super.displayNodeDidLoad() + + let interaction = PeerInfoControllerInteraction(openSharedMedia: { [weak self] in + if let strongSelf = self { + if let controller = peerSharedMediaController(account: strongSelf.account, peerId: strongSelf.peerId) { + (strongSelf.navigationController as? NavigationController)?.pushViewController(controller) + } + } + }, changeNotificationNoteSettings: { [weak self] in + if let strongSelf = self { + let controller = ActionSheetController() + let dismissAction: () -> Void = { [weak controller] in + controller?.dismissAnimated() + } + let notificationAction: (Int32) -> Void = { [weak strongSelf] muteUntil in + if let strongSelf = strongSelf { + let muteState: PeerMuteState + if muteUntil <= 0 { + muteState = .unmuted + } else if muteUntil == Int32.max { + muteState = .muted(until: Int32.max) + } else { + muteState = .muted(until: Int32(Date().timeIntervalSince1970) + muteUntil) + } + strongSelf.changeSettingsDisposable.set(changePeerNotificationSettings(account: strongSelf.account, peerId: strongSelf.peerId, settings: TelegramPeerNotificationSettings(muteState: muteState, messageSound: PeerMessageSound.appDefault)).start()) + } + } + controller.setItemGroups([ + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "Enable", action: { + dismissAction() + notificationAction(0) + }), + ActionSheetButtonItem(title: "Mute for 1 hour", action: { + dismissAction() + notificationAction(1 * 60 * 60) + }), + ActionSheetButtonItem(title: "Mute for 8 hours", action: { + dismissAction() + notificationAction(8 * 60 * 60) + }), + ActionSheetButtonItem(title: "Mute for 2 days", action: { + dismissAction() + notificationAction(2 * 24 * 60 * 60) + }), + ActionSheetButtonItem(title: "Disable", action: { + dismissAction() + notificationAction(Int32.max) + }) + ]), + ActionSheetItemGroup(items: [ActionSheetButtonItem(title: "Cancel", action: { dismissAction() })]) + ]) + strongSelf.present(controller, in: .window) + } + }) + + self.listDisplayNode.backgroundColor = UIColor.white + + let previousEntries = Atomic<[ChannelInfoEntry]?>(value: nil) + + let account = self.account + let transition = channelBroadcastInfoEntries(account: self.account, peerId: self.peerId) + |> map { entries -> (ChannelInfoEntryTransition, Bool, Bool) in + let previous = previousEntries.swap(entries) + return (preparedUserInfoEntryTransition(account: account, from: previous ?? [], to: entries, interaction: interaction), previous == nil, previous != nil) + } + |> deliverOnMainQueue + + self.transitionDisposable.set(transition.start(next: { [weak self] (transition, firstTime, animated) in + self?.enqueueTransition(transition, firstTime: firstTime, animated: animated) + })) + } + + private func enqueueTransition(_ transition: ChannelInfoEntryTransition, firstTime: Bool, animated: Bool) { + var options = ListViewDeleteAndInsertOptions() + if firstTime { + options.insert(.Synchronous) + options.insert(.LowLatency) + } else if animated { + options.insert(.AnimateInsertion) + } + self.listDisplayNode.listView.deleteAndInsertItems(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, completion: { [weak self] _ in + if let strongSelf = self { + if !strongSelf.didSetReady { + strongSelf.didSetReady = true + strongSelf._ready.set(.single(true)) + } + } + }) + } +} diff --git a/TelegramUI/ChatController.swift b/TelegramUI/ChatController.swift index 0d51649eb4..69890e183b 100644 --- a/TelegramUI/ChatController.swift +++ b/TelegramUI/ChatController.swift @@ -6,399 +6,26 @@ import Display import AsyncDisplayKit import TelegramCore -private enum ChatControllerScrollPosition { - case Unread(index: MessageIndex) - case Index(index: MessageIndex, position: ListViewScrollPosition, directionHint: ListViewScrollToItemDirectionHint, animated: Bool) -} - -private enum ChatHistoryViewUpdateType { - case Initial(fadeIn: Bool) - case Generic(type: ViewUpdateType) -} - -private enum ChatHistoryViewUpdate { - case Loading - case HistoryView(view: MessageHistoryView, type: ChatHistoryViewUpdateType, scrollPosition: ChatControllerScrollPosition?) -} - -private struct ChatHistoryView { - let originalView: MessageHistoryView - let filteredEntries: [ChatHistoryEntry] -} - -private enum ChatHistoryViewTransitionReason { - case Initial(fadeIn: Bool) - case InteractiveChanges - case HoleChanges(filledHoleDirections: [MessageIndex: HoleFillDirection], removeHoleDirections: [MessageIndex: HoleFillDirection]) - case Reload -} - -private struct ChatHistoryViewTransition { - let historyView: ChatHistoryView - let deleteItems: [ListViewDeleteItem] - let insertItems: [ListViewInsertItem] - let updateItems: [ListViewUpdateItem] - let options: ListViewDeleteAndInsertOptions - let scrollToItem: ListViewScrollToItem? - let stationaryItemRange: (Int, Int)? -} - -private func messageHistoryViewForLocation(_ location: ChatHistoryLocation, account: Account, peerId: PeerId, fixedCombinedReadState: CombinedPeerReadState?, tagMask: MessageTags?) -> Signal { - switch location { - case let .Initial(count): - var preloaded = false - var fadeIn = false - return account.viewTracker.aroundUnreadMessageHistoryViewForPeerId(peerId, count: count, tagMask: tagMask) |> map { view, updateType -> ChatHistoryViewUpdate in - if preloaded { - return .HistoryView(view: view, type: .Generic(type: updateType), scrollPosition: nil) - } else { - if let maxReadIndex = view.maxReadIndex { - var targetIndex = 0 - for i in 0 ..< view.entries.count { - if view.entries[i].index >= maxReadIndex { - targetIndex = i - break - } - } - - let maxIndex = min(view.entries.count, targetIndex + count / 2) - if maxIndex >= targetIndex { - for i in targetIndex ..< maxIndex { - if case .HoleEntry = view.entries[i] { - fadeIn = true - return .Loading - } - } - } - - preloaded = true - return .HistoryView(view: view, type: .Initial(fadeIn: fadeIn), scrollPosition: .Unread(index: maxReadIndex)) - } else { - preloaded = true - return .HistoryView(view: view, type: .Initial(fadeIn: fadeIn), scrollPosition: nil) - } - } - } - case let .InitialSearch(messageId, count): - var preloaded = false - var fadeIn = false - return account.viewTracker.aroundIdMessageHistoryViewForPeerId(peerId, count: count, messageId: messageId, tagMask: tagMask) |> map { view, updateType -> ChatHistoryViewUpdate in - if preloaded { - return .HistoryView(view: view, type: .Generic(type: updateType), scrollPosition: nil) - } else { - let anchorIndex = view.anchorIndex - - var targetIndex = 0 - for i in 0 ..< view.entries.count { - if view.entries[i].index >= anchorIndex { - targetIndex = i - break - } - } - - let maxIndex = min(view.entries.count, targetIndex + count / 2) - if maxIndex >= targetIndex { - for i in targetIndex ..< maxIndex { - if case .HoleEntry = view.entries[i] { - fadeIn = true - return .Loading - } - } - } - - preloaded = true - //case Index(index: MessageIndex, position: ListViewScrollPosition, directionHint: ListViewScrollToItemDirectionHint, animated: Bool) - return .HistoryView(view: view, type: .Initial(fadeIn: fadeIn), scrollPosition: .Index(index: anchorIndex, position: .Center(.Bottom), directionHint: .Down, animated: false)) - } - } - case let .Navigation(index, anchorIndex): - trace("messageHistoryViewForLocation navigation \(index.id.id)") - var first = true - return account.viewTracker.aroundMessageHistoryViewForPeerId(peerId, index: index, count: 140, anchorIndex: anchorIndex, fixedCombinedReadState: fixedCombinedReadState, tagMask: tagMask) |> map { view, updateType -> ChatHistoryViewUpdate in - let genericType: ViewUpdateType - if first { - first = false - genericType = ViewUpdateType.UpdateVisible - } else { - genericType = updateType - } - return .HistoryView(view: view, type: .Generic(type: genericType), scrollPosition: nil) - } - case let .Scroll(index, anchorIndex, sourceIndex, scrollPosition, animated): - let directionHint: ListViewScrollToItemDirectionHint = sourceIndex > index ? .Down : .Up - let chatScrollPosition = ChatControllerScrollPosition.Index(index: index, position: scrollPosition, directionHint: directionHint, animated: animated) - var first = true - return account.viewTracker.aroundMessageHistoryViewForPeerId(peerId, index: index, count: 140, anchorIndex: anchorIndex, fixedCombinedReadState: fixedCombinedReadState, tagMask: tagMask) |> map { view, updateType -> ChatHistoryViewUpdate in - let genericType: ViewUpdateType - let scrollPosition: ChatControllerScrollPosition? = first ? chatScrollPosition : nil - if first { - first = false - genericType = ViewUpdateType.UpdateVisible - } else { - genericType = updateType - } - return .HistoryView(view: view, type: .Generic(type: genericType), scrollPosition: scrollPosition) - } - } -} - -private func historyEntriesForView(_ view: MessageHistoryView) -> [ChatHistoryEntry] { - var entries: [ChatHistoryEntry] = [] - - for entry in view.entries { - switch entry { - case let .HoleEntry(hole, _): - entries.append(.HoleEntry(hole)) - case let .MessageEntry(message, _): - entries.append(.MessageEntry(message)) - } - } - - if let maxReadIndex = view.maxReadIndex { - var inserted = false - var i = 0 - let unreadEntry: ChatHistoryEntry = .UnreadEntry(maxReadIndex) - for entry in entries { - if entry > unreadEntry { - entries.insert(unreadEntry, at: i) - inserted = true - - break - } - i += 1 - } - if !inserted { - //entries.append(.UnreadEntry(maxReadIndex)) - } - } - - return entries -} - -private func preparedHistoryViewTransition(from fromView: ChatHistoryView?, to toView: ChatHistoryView, reason: ChatHistoryViewTransitionReason, account: Account, peerId: PeerId, controllerInteraction: ChatControllerInteraction, scrollPosition: ChatControllerScrollPosition?) -> Signal { - return Signal { subscriber in - let updateIndices: [(Int, ChatHistoryEntry)] = [] - //let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromView?.filteredEntries ?? [], rightList: toView.filteredEntries) - let (deleteIndices, indicesAndItems) = mergeListsStable(leftList: fromView?.filteredEntries ?? [], rightList: toView.filteredEntries) - - var adjustedDeleteIndices: [ListViewDeleteItem] = [] - let previousCount: Int - if let fromView = fromView { - previousCount = fromView.filteredEntries.count - } else { - previousCount = 0; - } - for index in deleteIndices { - adjustedDeleteIndices.append(ListViewDeleteItem(index: previousCount - 1 - index, directionHint: nil)) - } - - var adjustedIndicesAndItems: [ListViewInsertItem] = [] - var adjustedUpdateItems: [ListViewUpdateItem] = [] - let updatedCount = toView.filteredEntries.count - - var options: ListViewDeleteAndInsertOptions = [] - var maxAnimatedInsertionIndex = -1 - var stationaryItemRange: (Int, Int)? - var scrollToItem: ListViewScrollToItem? - - switch reason { - case let .Initial(fadeIn): - if fadeIn { - let _ = options.insert(.AnimateAlpha) - } else { - let _ = options.insert(.LowLatency) - let _ = options.insert(.Synchronous) - } - case .InteractiveChanges: - let _ = options.insert(.AnimateAlpha) - let _ = options.insert(.AnimateInsertion) - - for (index, _, _) in indicesAndItems.sorted(by: { $0.0 > $1.0 }) { - let adjustedIndex = updatedCount - 1 - index - if adjustedIndex == maxAnimatedInsertionIndex + 1 { - maxAnimatedInsertionIndex += 1 - } - } - case .Reload: - break - case let .HoleChanges(filledHoleDirections, removeHoleDirections): - if let (_, removeDirection) = removeHoleDirections.first { - switch removeDirection { - case .LowerToUpper: - var holeIndex: MessageIndex? - for (index, _) in filledHoleDirections { - if holeIndex == nil || index < holeIndex! { - holeIndex = index - } - } - - if let holeIndex = holeIndex { - for i in 0 ..< toView.filteredEntries.count { - if toView.filteredEntries[i].index >= holeIndex { - let index = toView.filteredEntries.count - 1 - (i - 1) - stationaryItemRange = (index, Int.max) - break - } - } - } - case .UpperToLower: - break - case .AroundIndex: - break - } - } - } - - for (index, entry, previousIndex) in indicesAndItems { - let adjustedIndex = updatedCount - 1 - index - - let adjustedPrevousIndex: Int? - if let previousIndex = previousIndex { - adjustedPrevousIndex = previousCount - 1 - previousIndex - } else { - adjustedPrevousIndex = nil - } - - var directionHint: ListViewItemOperationDirectionHint? - if maxAnimatedInsertionIndex >= 0 && adjustedIndex <= maxAnimatedInsertionIndex { - directionHint = .Down - } - - switch entry { - case let .MessageEntry(message): - adjustedIndicesAndItems.append(ListViewInsertItem(index: adjustedIndex, previousIndex: adjustedPrevousIndex, item: ChatMessageItem(account: account, peerId: peerId, controllerInteraction: controllerInteraction, message: message), directionHint: directionHint)) - case .HoleEntry: - adjustedIndicesAndItems.append(ListViewInsertItem(index: adjustedIndex, previousIndex: adjustedPrevousIndex, item: ChatHoleItem(), directionHint: directionHint)) - case .UnreadEntry: - adjustedIndicesAndItems.append(ListViewInsertItem(index: adjustedIndex, previousIndex: adjustedPrevousIndex, item: ChatUnreadItem(), directionHint: directionHint)) - } - } - - for (index, entry) in updateIndices { - let adjustedIndex = updatedCount - 1 - index - - let directionHint: ListViewItemOperationDirectionHint? = nil - - switch entry { - case let .MessageEntry(message): - adjustedUpdateItems.append(ListViewUpdateItem(index: adjustedIndex, item: ChatMessageItem(account: account, peerId: peerId, controllerInteraction: controllerInteraction, message: message), directionHint: directionHint)) - case .HoleEntry: - adjustedUpdateItems.append(ListViewUpdateItem(index: adjustedIndex, item: ChatHoleItem(), directionHint: directionHint)) - case .UnreadEntry: - adjustedUpdateItems.append(ListViewUpdateItem(index: adjustedIndex, item: ChatUnreadItem(), directionHint: directionHint)) - } - } - - if let scrollPosition = scrollPosition { - switch scrollPosition { - case let .Unread(unreadIndex): - var index = toView.filteredEntries.count - 1 - for entry in toView.filteredEntries { - if case .UnreadEntry = entry { - scrollToItem = ListViewScrollToItem(index: index, position: .Bottom, animated: false, curve: .Default, directionHint: .Down) - break - } - index -= 1 - } - - if scrollToItem == nil { - var index = toView.filteredEntries.count - 1 - for entry in toView.filteredEntries { - if entry.index >= unreadIndex { - scrollToItem = ListViewScrollToItem(index: index, position: .Bottom, animated: false, curve: .Default, directionHint: .Down) - break - } - index -= 1 - } - } - - if scrollToItem == nil { - var index = 0 - for entry in toView.filteredEntries.reversed() { - if entry.index < unreadIndex { - scrollToItem = ListViewScrollToItem(index: index, position: .Bottom, animated: false, curve: .Default, directionHint: .Down) - break - } - index += 1 - } - } - case let .Index(scrollIndex, position, directionHint, animated): - var index = toView.filteredEntries.count - 1 - for entry in toView.filteredEntries { - if entry.index >= scrollIndex { - scrollToItem = ListViewScrollToItem(index: index, position: position, animated: animated, curve: .Default, directionHint: directionHint) - break - } - index -= 1 - } - - if scrollToItem == nil { - var index = 0 - for entry in toView.filteredEntries.reversed() { - if entry.index < scrollIndex { - scrollToItem = ListViewScrollToItem(index: index, position: position, animated: animated, curve: .Default, directionHint: directionHint) - break - } - index += 1 - } - } - } - } - - subscriber.putNext(ChatHistoryViewTransition(historyView: toView, deleteItems: adjustedDeleteIndices, insertItems: adjustedIndicesAndItems, updateItems: adjustedUpdateItems, options: options, scrollToItem: scrollToItem, stationaryItemRange: stationaryItemRange)) - subscriber.putCompletion() - - return EmptyDisposable - } -} - -private func maxIncomingMessageIdForEntries(_ entries: [ChatHistoryEntry], indexRange: (Int, Int)) -> MessageId? { - for i in (indexRange.0 ... indexRange.1).reversed() { - if case let .MessageEntry(message) = entries[i], message.flags.contains(.Incoming) { - return message.id - } - } - return nil -} - -private var useDarkMode = false - public class ChatController: ViewController { private var containerLayout = ContainerViewLayout() private let account: Account private let peerId: PeerId private let messageId: MessageId? - - private var historyView: ChatHistoryView? private let peerDisposable = MetaDisposable() - private let historyDisposable = MetaDisposable() - private let readHistoryDisposable = MetaDisposable() - - private let messageViewQueue = Queue() + private let navigationActionDisposable = MetaDisposable() private let messageIndexDisposable = MetaDisposable() - private var enqueuedHistoryViewTransition: (ChatHistoryViewTransition, () -> Void)? - private var layoutActionOnViewTransition: (@escaping () -> Void)? - - private let _historyReady = Promise() - private var didSetHistoryReady = false private let _peerReady = Promise() private var didSetPeerReady = false - - private let maxVisibleIncomingMessageId = Promise() - private let canReadHistory = Promise() - - private let _chatHistoryLocation = Promise() - private var chatHistoryLocation: Signal { - return self._chatHistoryLocation.get() - } + private let peerView = Promise() private var presentationInterfaceState = ChatPresentationInterfaceState(interfaceState: ChatInterfaceState(), peer: nil, inputContext: nil) private let chatInterfaceStatePromise = Promise() + private var chatTitleView: ChatTitleView? private var leftNavigationButton: ChatNavigationButton? private var rightNavigationButton: ChatNavigationButton? private var chatInfoNavigationButton: ChatNavigationButton? @@ -415,20 +42,20 @@ public class ChatController: ViewController { super.init() - self.ready.set(combineLatest(self._historyReady.get(), self._peerReady.get()) |> map { $0 && $1 }) + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) - self.setupThemeWithDarkMode(useDarkMode) + self.ready.set(.never()) self.scrollToTop = { [weak self] in - if let strongSelf = self { - strongSelf._chatHistoryLocation.set(.single(ChatHistoryLocation.Scroll(index: MessageIndex.lowerBound(peerId: strongSelf.peerId), anchorIndex: MessageIndex.lowerBound(peerId: strongSelf.peerId), sourceIndex: MessageIndex.upperBound(peerId: strongSelf.peerId), scrollPosition: .Bottom, animated: true))) + if let strongSelf = self, strongSelf.isNodeLoaded { + strongSelf.chatDisplayNode.historyNode.scrollToStartOfHistory() } } let controllerInteraction = ChatControllerInteraction(openMessage: { [weak self] id in - if let strongSelf = self, let historyView = strongSelf.historyView { + if let strongSelf = self, strongSelf.isNodeLoaded { var galleryMedia: Media? - for case let .MessageEntry(message) in historyView.filteredEntries where message.id == id { + if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(id) { for media in message.media { if let file = media as? TelegramMediaFile { galleryMedia = file @@ -442,7 +69,6 @@ public class ChatController: ViewController { } } } - break } if let galleryMedia = galleryMedia { @@ -458,7 +84,7 @@ public class ChatController: ViewController { } else { strongSelf.controllerInteraction?.hiddenMedia = [:] } - strongSelf.chatDisplayNode.listView.forEachItemNode { itemNode in + strongSelf.chatDisplayNode.historyNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ChatMessageItemView { itemNode.updateHiddenMedia() } @@ -466,17 +92,19 @@ public class ChatController: ViewController { } })) - strongSelf.present(gallery, in: .window, with: GalleryControllerPresentationArguments(transitionNode: { [weak self] messageId, media in + strongSelf.present(gallery, in: .window, with: GalleryControllerPresentationArguments(transitionArguments: { [weak self] messageId, media in if let strongSelf = self { var transitionNode: ASDisplayNode? - strongSelf.chatDisplayNode.listView.forEachItemNode { itemNode in + strongSelf.chatDisplayNode.historyNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ChatMessageItemView { if let result = itemNode.transitionNode(id: messageId, media: media) { transitionNode = result } } } - return transitionNode + if let transitionNode = transitionNode { + return GalleryTransitionArguments(transitionNode: transitionNode, transitionContainerNode: strongSelf.chatDisplayNode, transitionBackgroundNode: strongSelf.chatDisplayNode.historyNode) + } } return nil })) @@ -488,45 +116,35 @@ public class ChatController: ViewController { (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: id, messageId: nil)) } }, openMessageContextMenu: { [weak self] id, node, frame in - if let strongSelf = self, let historyView = strongSelf.historyView { - if let strongSelf = self, let historyView = strongSelf.historyView { - for case let .MessageEntry(message) in historyView.filteredEntries where message.id == id { - if let contextMenuController = contextMenuForChatPresentationIntefaceState(strongSelf.presentationInterfaceState, account: strongSelf.account, message: message, interfaceInteraction: strongSelf.interfaceInteraction) { - strongSelf.present(contextMenuController, in: .window, with: ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak strongSelf, weak node] in - if let node = node { - return (node, frame) - } else { - return nil - } - })) - } - - break + if let strongSelf = self, strongSelf.isNodeLoaded { + if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(id) { + if let contextMenuController = contextMenuForChatPresentationIntefaceState(strongSelf.presentationInterfaceState, account: strongSelf.account, message: message, interfaceInteraction: strongSelf.interfaceInteraction) { + strongSelf.present(contextMenuController, in: .window, with: ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak strongSelf, weak node] in + if let node = node { + return (node, frame) + } else { + return nil + } + })) } } } }, navigateToMessage: { [weak self] fromId, id in - if let strongSelf = self, let historyView = strongSelf.historyView { + if let strongSelf = self, strongSelf.isNodeLoaded { if id.peerId == strongSelf.peerId { var fromIndex: MessageIndex? - for case let .MessageEntry(message) in historyView.filteredEntries where message.id == fromId { + if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(fromId) { fromIndex = MessageIndex(message) - break } if let fromIndex = fromIndex { - var found = false - for case let .MessageEntry(message) in historyView.filteredEntries where message.id == id { - found = true - - strongSelf._chatHistoryLocation.set(.single(ChatHistoryLocation.Scroll(index: MessageIndex(message), anchorIndex: MessageIndex(message), sourceIndex: fromIndex, scrollPosition: .Center(.Bottom), animated: true))) - } - - if !found { + if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(id) { + strongSelf.chatDisplayNode.historyNode.scrollToMessage(from: fromIndex, to: MessageIndex(message)) + } else { strongSelf.messageIndexDisposable.set((strongSelf.account.postbox.messageIndexAtId(id) |> deliverOnMainQueue).start(next: { [weak strongSelf] index in if let strongSelf = strongSelf, let index = index { - strongSelf._chatHistoryLocation.set(.single(ChatHistoryLocation.Scroll(index:index, anchorIndex: index, sourceIndex: fromIndex, scrollPosition: .Center(.Bottom), animated: true))) + strongSelf.chatDisplayNode.historyNode.scrollToMessage(from: fromIndex, to: index) } })) } @@ -537,131 +155,42 @@ public class ChatController: ViewController { } }, clickThroughMessage: { [weak self] in self?.view.endEditing(true) - }, toggleMessageSelection: { [weak self] messageId in - if let strongSelf = self, let historyView = strongSelf.historyView { - for case let .MessageEntry(message) in historyView.filteredEntries where message.id == messageId { - strongSelf.updateChatPresentationInterfaceState(animated: false, { $0.updatedInterfaceState { $0.withToggledSelectedMessage(messageId) } }) - break + }, toggleMessageSelection: { [weak self] id in + if let strongSelf = self, strongSelf.isNodeLoaded { + if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(id) { + strongSelf.updateChatPresentationInterfaceState(animated: false, { $0.updatedInterfaceState { $0.withToggledSelectedMessage(id) } }) } } }) self.controllerInteraction = controllerInteraction - let messageViewQueue = self.messageViewQueue + self.chatTitleView = ChatTitleView(frame: CGRect()) + self.navigationItem.titleView = self.chatTitleView - self.chatInfoNavigationButton = ChatNavigationButton(action: .openChatInfo, buttonItem: UIBarButtonItem(customDisplayNode: ChatAvatarNavigationNode())) + let chatInfoButtonItem = UIBarButtonItem(customDisplayNode: ChatAvatarNavigationNode())! + chatInfoButtonItem.target = self + chatInfoButtonItem.action = #selector(self.rightNavigationButtonAction) + self.chatInfoNavigationButton = ChatNavigationButton(action: .openChatInfo, buttonItem: chatInfoButtonItem) self.updateChatPresentationInterfaceState(animated: false, { return $0 }) - peerDisposable.set((account.postbox.peerWithId(peerId) - |> deliverOnMainQueue).start(next: { [weak self] peer in + self.peerView.set(account.viewTracker.peerView(peerId)) + + peerDisposable.set((self.peerView.get() + |> deliverOnMainQueue).start(next: { [weak self] peerView in if let strongSelf = self { - if let peer = peer { - strongSelf.title = peer.displayTitle + if let peer = peerView.peers[peerId] { + strongSelf.chatTitleView?.peerView = peerView (strongSelf.chatInfoNavigationButton?.buttonItem.customDisplayNode as? ChatAvatarNavigationNode)?.avatarNode.setPeer(account: strongSelf.account, peer: peer) } - strongSelf.updateChatPresentationInterfaceState(animated: false, { return $0.updatedPeer { _ in return peer } }) + strongSelf.updateChatPresentationInterfaceState(animated: false, { return $0.updatedPeer { _ in return peerView.peers[peerId] } }) if !strongSelf.didSetPeerReady { strongSelf.didSetPeerReady = true strongSelf._peerReady.set(.single(true)) } } })) - - let fixedCombinedReadState = Atomic(value: nil) - - let historyViewUpdate = self.chatHistoryLocation - |> distinctUntilChanged - |> mapToSignal { location in - return messageHistoryViewForLocation(location, account: account, peerId: peerId, fixedCombinedReadState: fixedCombinedReadState.with { $0 }, tagMask: nil) |> beforeNext { viewUpdate in - switch viewUpdate { - case let .HistoryView(view, _, _): - let _ = fixedCombinedReadState.swap(view.combinedReadState) - default: - break - } - } - } - - let previousView = Atomic(value: nil) - - let historyViewTransition = historyViewUpdate |> mapToQueue { [weak self] update -> Signal in - switch update { - case .Loading: - Queue.mainQueue().async { [weak self] in - if let strongSelf = self { - if !strongSelf.didSetHistoryReady { - strongSelf.didSetHistoryReady = true - strongSelf._historyReady.set(.single(true)) - } - } - } - return .complete() - case let .HistoryView(view, type, scrollPosition): - let reason: ChatHistoryViewTransitionReason - var prepareOnMainQueue = false - switch type { - case let .Initial(fadeIn): - reason = ChatHistoryViewTransitionReason.Initial(fadeIn: fadeIn) - prepareOnMainQueue = !fadeIn - case let .Generic(genericType): - switch genericType { - case .InitialUnread: - reason = ChatHistoryViewTransitionReason.Initial(fadeIn: false) - case .Generic: - reason = ChatHistoryViewTransitionReason.InteractiveChanges - case .UpdateVisible: - reason = ChatHistoryViewTransitionReason.Reload - case let .FillHole(insertions, deletions): - reason = ChatHistoryViewTransitionReason.HoleChanges(filledHoleDirections: insertions, removeHoleDirections: deletions) - } - } - - let processedView = ChatHistoryView(originalView: view, filteredEntries: historyEntriesForView(view)) - let previous = previousView.swap(processedView) - - return preparedHistoryViewTransition(from: previous, to: processedView, reason: reason, account: account, peerId: peerId, controllerInteraction: controllerInteraction, scrollPosition: scrollPosition) |> runOn( prepareOnMainQueue ? Queue.mainQueue() : messageViewQueue) - } - } - - let appliedTransition = historyViewTransition |> deliverOnMainQueue |> mapToQueue { [weak self] transition -> Signal in - if let strongSelf = self { - return strongSelf.enqueueHistoryViewTransition(transition) - } - return .complete() - } - - self.historyDisposable.set(appliedTransition.start()) - - let previousMaxIncomingMessageId = Atomic(value: nil) - let readHistory = combineLatest(self.maxVisibleIncomingMessageId.get(), self.canReadHistory.get()) - |> map { messageId, canRead in - if canRead { - var apply = false - let _ = previousMaxIncomingMessageId.modify { previousId in - if previousId == nil || previousId! < messageId { - apply = true - return messageId - } else { - return previousId - } - } - if apply { - let _ = account.postbox.modify({ modifier in - modifier.applyInteractiveReadMaxId(messageId) - }).start() - } - } - } - - self.readHistoryDisposable.set(readHistory.start()) - - if let messageId = messageId { - self._chatHistoryLocation.set(.single(ChatHistoryLocation.InitialSearch(messageId: messageId, count: 60))) - } else { - self._chatHistoryLocation.set(.single(ChatHistoryLocation.Initial(count: 60))) - } } required public init(coder aDecoder: NSCoder) { @@ -669,26 +198,10 @@ public class ChatController: ViewController { } deinit { - self.historyDisposable.dispose() - self.readHistoryDisposable.dispose() self.messageIndexDisposable.dispose() + self.navigationActionDisposable.dispose() self.galleryHiddenMesageAndMediaDisposable.dispose() - } - - private func setupThemeWithDarkMode(_ darkMode: Bool) { - if darkMode { - self.statusBar.style = .White - self.navigationBar.backgroundColor = UIColor(white: 0.0, alpha: 0.9) - self.navigationBar.foregroundColor = UIColor.white - self.navigationBar.accentColor = UIColor.white - self.navigationBar.stripeColor = UIColor.black - } else { - self.statusBar.style = .Black - self.navigationBar.backgroundColor = UIColor(red: 0.968626451, green: 0.968626451, blue: 0.968626451, alpha: 1.0) - self.navigationBar.foregroundColor = UIColor.black - self.navigationBar.accentColor = UIColor(0x1195f2) - self.navigationBar.stripeColor = UIColor(red: 0.6953125, green: 0.6953125, blue: 0.6953125, alpha: 1.0) - } + self.peerDisposable.dispose() } var chatDisplayNode: ChatControllerNode { @@ -698,35 +211,11 @@ public class ChatController: ViewController { } override public func loadDisplayNode() { - self.displayNode = ChatControllerNode(account: self.account, peerId: self.peerId) + self.displayNode = ChatControllerNode(account: self.account, peerId: self.peerId, messageId: self.messageId, controllerInteraction: self.controllerInteraction!) - self.chatDisplayNode.listView.displayedItemRangeChanged = { [weak self] displayedRange in - if let strongSelf = self { - /*if let transactionTag = strongSelf.listViewTransactionTag { - strongSelf.messageViewQueue.dispatch { - if transactionTag == strongSelf.historyViewTransactionTag { - if let range = range, historyView = strongSelf.historyView, firstEntry = historyView.filteredEntries.first, lastEntry = historyView.filteredEntries.last { - if range.firstIndex < 5 && historyView.originalView.laterId != nil { - strongSelf._chatHistoryLocation.set(.single(ChatHistoryLocation.Navigation(index: lastEntry.index, anchorIndex: historyView.originalView.anchorIndex))) - } else if range.lastIndex >= historyView.filteredEntries.count - 5 && historyView.originalView.earlierId != nil { - strongSelf._chatHistoryLocation.set(.single(ChatHistoryLocation.Navigation(index: firstEntry.index, anchorIndex: historyView.originalView.anchorIndex))) - } else { - //strongSelf.account.postbox.updateMessageHistoryViewVisibleRange(messageView.id, earliestVisibleIndex: viewEntries[viewEntries.count - 1 - range.lastIndex].index, latestVisibleIndex: viewEntries[viewEntries.count - 1 - range.firstIndex].index) - } - } - } - } - }*/ - - if let visible = displayedRange.visibleRange, let historyView = strongSelf.historyView { - if let messageId = maxIncomingMessageIdForEntries(historyView.filteredEntries, indexRange: (historyView.filteredEntries.count - 1 - visible.lastIndex, historyView.filteredEntries.count - 1 - visible.firstIndex)) { - strongSelf.updateMaxVisibleReadIncomingMessageId(messageId) - } - } - } - } + self.ready.set(combineLatest(self.chatDisplayNode.historyNode.historyReady.get(), self._peerReady.get()) |> map { $0 && $1 }) - self.chatDisplayNode.listView.visibleContentOffsetChanged = { [weak self] offset in + self.chatDisplayNode.historyNode.visibleContentOffsetChanged = { [weak self] offset in if let strongSelf = self { let offsetAlpha: CGFloat switch offset { @@ -755,7 +244,47 @@ public class ChatController: ViewController { } self.chatDisplayNode.setupSendActionOnViewUpdate = { [weak self] f in - self?.layoutActionOnViewTransition = f + self?.chatDisplayNode.historyNode.layoutActionOnViewTransition = { [weak self] transition in + f() + if let strongSelf = self { + var mappedTransition: (ChatHistoryListViewTransition, ListViewUpdateSizeAndInsets?)? + + strongSelf.chatDisplayNode.containerLayoutUpdated(strongSelf.containerLayout, navigationBarHeight: strongSelf.navigationBar.frame.maxY, transition: .animated(duration: 0.4, curve: .spring), listViewTransaction: { updateSizeAndInsets in + var options = transition.options + let _ = options.insert(.Synchronous) + let _ = options.insert(.LowLatency) + options.remove(.AnimateInsertion) + + let deleteItems = transition.deleteItems.map({ item in + return ListViewDeleteItem(index: item.index, directionHint: nil) + }) + + var maxInsertedItem: Int? + var insertItems: [ListViewInsertItem] = [] + for i in 0 ..< transition.insertItems.count { + let item = transition.insertItems[i] + if item.directionHint == .Down && (maxInsertedItem == nil || maxInsertedItem! < item.index) { + maxInsertedItem = item.index + } + insertItems.append(ListViewInsertItem(index: item.index, previousIndex: item.previousIndex, item: item.item, directionHint: item.directionHint == .Down ? .Up : nil)) + } + + let scrollToItem = ListViewScrollToItem(index: 0, position: .Top, animated: true, curve: .Spring(duration: 0.4), directionHint: .Up) + + var stationaryItemRange: (Int, Int)? + if let maxInsertedItem = maxInsertedItem { + stationaryItemRange = (maxInsertedItem + 1, Int.max) + } + + mappedTransition = (ChatHistoryListViewTransition(historyView: transition.historyView, deleteItems: deleteItems, insertItems: insertItems, updateItems: transition.updateItems, options: options, scrollToItem: scrollToItem, stationaryItemRange: stationaryItemRange), updateSizeAndInsets) + }) + + if let mappedTransition = mappedTransition { + return mappedTransition + } + } + return (transition, nil) + } } self.chatDisplayNode.requestUpdateChatInterfaceState = { [weak self] animated, f in @@ -773,8 +302,6 @@ public class ChatController: ViewController { } controller.contacts = { [weak strongSelf] in if let strongSelf = strongSelf { - useDarkMode = !useDarkMode - strongSelf.setupThemeWithDarkMode(useDarkMode) } } strongSelf.present(controller, in: .window) @@ -782,29 +309,26 @@ public class ChatController: ViewController { } self.chatDisplayNode.navigateToLatestButton.tapped = { [weak self] in - if let strongSelf = self { - strongSelf._chatHistoryLocation.set(.single(ChatHistoryLocation.Scroll(index: MessageIndex.upperBound(peerId: strongSelf.peerId), anchorIndex: MessageIndex.upperBound(peerId: strongSelf.peerId), sourceIndex: MessageIndex.lowerBound(peerId: strongSelf.peerId), scrollPosition: .Top, animated: true))) + if let strongSelf = self, strongSelf.isNodeLoaded { + strongSelf.chatDisplayNode.historyNode.scrollToEndOfHistory() } } let interfaceInteraction = ChatPanelInterfaceInteraction(setupReplyMessage: { [weak self] messageId in - if let strongSelf = self, let historyView = strongSelf.historyView { - for case let .MessageEntry(message) in historyView.filteredEntries where message.id == messageId { + if let strongSelf = self, strongSelf.isNodeLoaded { + if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId) { strongSelf.updateChatPresentationInterfaceState(animated: true, { $0.updatedInterfaceState { $0.withUpdatedReplyMessageId(message.id) } }) strongSelf.chatDisplayNode.ensureInputViewFocused() - break } } }, beginMessageSelection: { [weak self] messageId in - if let strongSelf = self, let historyView = strongSelf.historyView { - for case let .MessageEntry(message) in historyView.filteredEntries where message.id == messageId { + if let strongSelf = self, strongSelf.isNodeLoaded { + if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId) { strongSelf.updateChatPresentationInterfaceState(animated: true, { $0.updatedInterfaceState { $0.withUpdatedSelectedMessage(message.id) } }) - break } } }, deleteSelectedMessages: { [weak self] in if let strongSelf = self { - if let messageIds = strongSelf.presentationInterfaceState.interfaceState.selectionState?.selectedIds, !messageIds.isEmpty { strongSelf.account.postbox.modify({ modifier in modifier.deleteMessages(Array(messageIds)) @@ -827,8 +351,6 @@ public class ChatController: ViewController { self.chatDisplayNode.interfaceInteraction = interfaceInteraction self.displayNodeDidLoad() - - self.dequeueHistoryViewTransition() } override public func viewWillAppear(_ animated: Bool) { @@ -838,105 +360,8 @@ public class ChatController: ViewController { override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - self.chatDisplayNode.listView.preloadPages = true - self.canReadHistory.set(.single(true)) - } - - private func enqueueHistoryViewTransition(_ transition: ChatHistoryViewTransition) -> Signal { - return Signal { [weak self] subscriber in - if let strongSelf = self { - if let _ = strongSelf.enqueuedHistoryViewTransition { - preconditionFailure() - } - - strongSelf.enqueuedHistoryViewTransition = (transition, { - subscriber.putCompletion() - }) - - if strongSelf.isNodeLoaded { - strongSelf.dequeueHistoryViewTransition() - } else { - if !strongSelf.didSetHistoryReady { - strongSelf.didSetHistoryReady = true - strongSelf._historyReady.set(.single(true)) - } - } - } else { - subscriber.putCompletion() - } - - return EmptyDisposable - } |> runOn(Queue.mainQueue()) - } - - private func updateMaxVisibleReadIncomingMessageId(_ id: MessageId) { - self.maxVisibleIncomingMessageId.set(.single(id)) - } - - private func dequeueHistoryViewTransition() { - if let (transition, completion) = self.enqueuedHistoryViewTransition { - self.enqueuedHistoryViewTransition = nil - - let completion: (ListViewDisplayedItemRange) -> Void = { [weak self] visibleRange in - if let strongSelf = self { - strongSelf.historyView = transition.historyView - - if let range = visibleRange.loadedRange { - strongSelf.account.postbox.updateMessageHistoryViewVisibleRange(transition.historyView.originalView.id, earliestVisibleIndex: transition.historyView.filteredEntries[transition.historyView.filteredEntries.count - 1 - range.lastIndex].index, latestVisibleIndex: transition.historyView.filteredEntries[transition.historyView.filteredEntries.count - 1 - range.firstIndex].index) - - if let visible = visibleRange.visibleRange { - if let messageId = maxIncomingMessageIdForEntries(transition.historyView.filteredEntries, indexRange: (transition.historyView.filteredEntries.count - 1 - visible.lastIndex, transition.historyView.filteredEntries.count - 1 - visible.firstIndex)) { - strongSelf.updateMaxVisibleReadIncomingMessageId(messageId) - } - } - } - - if !strongSelf.didSetHistoryReady { - strongSelf.didSetHistoryReady = true - strongSelf._historyReady.set(.single(true)) - } - - completion() - } - } - - if let layoutActionOnViewTransition = self.layoutActionOnViewTransition { - self.layoutActionOnViewTransition = nil - layoutActionOnViewTransition() - - self.chatDisplayNode.containerLayoutUpdated(self.containerLayout, navigationBarHeight: self.navigationBar.frame.maxY, transition: .animated(duration: 0.4, curve: .spring), listViewTransaction: { updateSizeAndInsets in - var options = transition.options - let _ = options.insert(.Synchronous) - let _ = options.insert(.LowLatency) - options.remove(.AnimateInsertion) - - let deleteItems = transition.deleteItems.map({ item in - return ListViewDeleteItem(index: item.index, directionHint: nil) - }) - - var maxInsertedItem: Int? - var insertItems: [ListViewInsertItem] = [] - for i in 0 ..< transition.insertItems.count { - let item = transition.insertItems[i] - if item.directionHint == .Down && (maxInsertedItem == nil || maxInsertedItem! < item.index) { - maxInsertedItem = item.index - } - insertItems.append(ListViewInsertItem(index: item.index, previousIndex: item.previousIndex, item: item.item, directionHint: item.directionHint == .Down ? .Up : nil)) - } - - let scrollToItem = ListViewScrollToItem(index: 0, position: .Top, animated: true, curve: .Spring(duration: 0.4), directionHint: .Up) - - var stationaryItemRange: (Int, Int)? - if let maxInsertedItem = maxInsertedItem { - stationaryItemRange = (maxInsertedItem + 1, Int.max) - } - - self.chatDisplayNode.listView.deleteAndInsertItems(deleteIndices: deleteItems, insertIndicesAndItems: insertItems, updateIndicesAndItems: transition.updateItems, options: options, scrollToItem: scrollToItem, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: stationaryItemRange, completion: completion) - }) - } else { - self.chatDisplayNode.listView.deleteAndInsertItems(deleteIndices: transition.deleteItems, insertIndicesAndItems: transition.insertItems, updateIndicesAndItems: transition.updateItems, options: transition.options, scrollToItem: transition.scrollToItem, stationaryItemRange: transition.stationaryItemRange, completion: completion) - } - } + self.chatDisplayNode.historyNode.preloadPages = true + self.chatDisplayNode.historyNode.canReadHistory.set(.single(true)) } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { @@ -945,7 +370,7 @@ public class ChatController: ViewController { self.containerLayout = layout self.chatDisplayNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationBar.frame.maxY, transition: transition, listViewTransaction: { updateSizeAndInsets in - self.chatDisplayNode.listView.deleteAndInsertItems(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, completion: { _ in }) + self.chatDisplayNode.historyNode.updateLayout(transition: transition, updateSizeAndInsets: updateSizeAndInsets) }) } @@ -980,7 +405,7 @@ public class ChatController: ViewController { if updatedChatPresentationInterfaceState.interfaceState.selectionState != controllerInteraction.selectionState { let animated = controllerInteraction.selectionState == nil || updatedChatPresentationInterfaceState.interfaceState.selectionState == nil controllerInteraction.selectionState = updatedChatPresentationInterfaceState.interfaceState.selectionState - self.chatDisplayNode.listView.forEachItemNode { itemNode in + self.chatDisplayNode.historyNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ChatMessageItemView { itemNode.updateSelectionState(animated: animated) } @@ -1018,6 +443,15 @@ public class ChatController: ViewController { ])]) self.present(actionSheet, in: .window) case .openChatInfo: + self.navigationActionDisposable.set((self.peerView.get() + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] peerView in + if let strongSelf = self, let peer = peerView.peers[peerView.peerId] { + if let chatInfoController = chatInfoController(account: strongSelf.account, peer: peer) { + (strongSelf.navigationController as? NavigationController)?.pushViewController(chatInfoController) + } + } + })) break } } diff --git a/TelegramUI/ChatControllerInteraction.swift b/TelegramUI/ChatControllerInteraction.swift index 8f7a741073..e726f11eaa 100644 --- a/TelegramUI/ChatControllerInteraction.swift +++ b/TelegramUI/ChatControllerInteraction.swift @@ -10,7 +10,7 @@ public enum ChatControllerInteractionNavigateToPeer { public final class ChatControllerInteraction { let openMessage: (MessageId) -> Void let openPeer: (PeerId, ChatControllerInteractionNavigateToPeer) -> Void - let openMessageContextMenu: @escaping (MessageId, ASDisplayNode, CGRect) -> Void + let openMessageContextMenu: (MessageId, ASDisplayNode, CGRect) -> Void let navigateToMessage: (MessageId, MessageId) -> Void let clickThroughMessage: () -> Void var hiddenMedia: [MessageId: [Media]] = [:] diff --git a/TelegramUI/ChatControllerNode.swift b/TelegramUI/ChatControllerNode.swift index 2e44168fb7..e12f3a711e 100644 --- a/TelegramUI/ChatControllerNode.swift +++ b/TelegramUI/ChatControllerNode.swift @@ -12,44 +12,12 @@ private func shouldRequestLayoutOnPresentationInterfaceStateTransition(_ lhs: Ch return false } -enum ChatMessageViewPosition: Equatable { - case AroundUnread(count: Int) - case Around(index: MessageIndex, anchorIndex: MessageIndex) - case Scroll(index: MessageIndex, anchorIndex: MessageIndex, sourceIndex: MessageIndex, scrollPosition: ListViewScrollPosition) -} - -func ==(lhs: ChatMessageViewPosition, rhs: ChatMessageViewPosition) -> Bool { - switch lhs { - case let .Around(lhsId, lhsAnchorIndex): - switch rhs { - case let .Around(rhsId, rhsAnchorIndex) where lhsId == rhsId && lhsAnchorIndex == rhsAnchorIndex: - return true - default: - return false - } - case let .Scroll(lhsIndex, lhsAnchorIndex, lhsSourceIndex, lhsScrollPosition): - switch rhs { - case let .Scroll(rhsIndex, rhsAnchorIndex, rhsSourceIndex, rhsScrollPosition) where lhsIndex == rhsIndex && lhsAnchorIndex == rhsAnchorIndex && lhsSourceIndex == rhsSourceIndex && lhsScrollPosition == rhsScrollPosition: - return true - default: - return false - } - case let .AroundUnread(lhsCount): - switch rhs { - case let .AroundUnread(rhsCount) where lhsCount == rhsCount: - return true - default: - return false - } - } -} - class ChatControllerNode: ASDisplayNode { let account: Account let peerId: PeerId let backgroundNode: ASDisplayNode - let listView: ListView + let historyNode: ChatHistoryListNode private let inputPanelBackgroundNode: ASDisplayNode private let inputPanelBackgroundSeparatorNode: ASDisplayNode @@ -73,7 +41,7 @@ class ChatControllerNode: ASDisplayNode { var interfaceInteraction: ChatPanelInterfaceInteraction? - init(account: Account, peerId: PeerId) { + init(account: Account, peerId: PeerId, messageId: MessageId?, controllerInteraction: ChatControllerInteraction) { self.account = account self.peerId = peerId @@ -83,8 +51,7 @@ class ChatControllerNode: ASDisplayNode { self.backgroundNode.displaysAsynchronously = false self.backgroundNode.clipsToBounds = true - self.listView = ListView() - self.listView.preloadPages = false + self.historyNode = ChatHistoryListNode(account: account, peerId: peerId, tagMask: nil, messageId: messageId, controllerInteraction: controllerInteraction) self.inputPanelBackgroundNode = ASDisplayNode() self.inputPanelBackgroundNode.backgroundColor = UIColor(0xfafafa) @@ -105,15 +72,14 @@ class ChatControllerNode: ASDisplayNode { self.backgroundNode.contents = backgroundImage?.cgImage self.addSubnode(self.backgroundNode) - self.listView.transform = CATransform3DMakeRotation(CGFloat(M_PI), 0.0, 0.0, 1.0) - self.addSubnode(self.listView) + self.addSubnode(self.historyNode) self.addSubnode(self.inputPanelBackgroundNode) self.addSubnode(self.inputPanelBackgroundSeparatorNode) self.addSubnode(self.navigateToLatestButton) - self.listView.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + self.historyNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) self.textInputPanelNode = ChatTextInputPanelNode() self.textInputPanelNode?.updateHeight = { [weak self] in @@ -150,9 +116,6 @@ class ChatControllerNode: ASDisplayNode { var insets = layout.insets(options: [.input]) insets.top += navigationBarHeight - self.listView.bounds = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) - self.listView.position = CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0) - var duration: Double = 0.0 var curve: UInt = 0 switch transition { @@ -170,8 +133,8 @@ class ChatControllerNode: ASDisplayNode { self.backgroundNode.frame = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) - self.listView.bounds = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) - self.listView.position = CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0) + self.historyNode.bounds = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) + self.historyNode.position = CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0) let listViewCurve: ListViewAnimationCurve if curve == 7 { @@ -180,8 +143,6 @@ class ChatControllerNode: ASDisplayNode { listViewCurve = .Default } - //let inputViewFrame = CGRect(x: 0.0, y: layout.size.height - messageTextInputSize.height - insets.bottom, width: layout.size.width, height: messageTextInputSize.height) - var dismissedInputPanelNode: ASDisplayNode? var dismissedAccessoryPanelNode: ASDisplayNode? var dismissedInputContextPanelNode: ChatInputContextPanelNode? diff --git a/TelegramUI/ChatDocumentGalleryItem.swift b/TelegramUI/ChatDocumentGalleryItem.swift index 8e4823462d..a274cef0c9 100644 --- a/TelegramUI/ChatDocumentGalleryItem.swift +++ b/TelegramUI/ChatDocumentGalleryItem.swift @@ -135,4 +135,63 @@ class ChatDocumentGalleryItemNode: GalleryItemNode { override func title() -> Signal { return self._title.get() } + + override func animateIn(from node: ASDisplayNode) { + var transformedFrame = node.view.convert(node.view.bounds, to: self.webView) + let transformedSuperFrame = node.view.convert(node.view.bounds, to: self.webView.superview) + + self.webView.layer.animatePosition(from: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), to: self.webView.layer.position, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring) + + transformedFrame.origin = CGPoint() + + let transform = CATransform3DScale(self.webView.layer.transform, transformedFrame.size.width / self.webView.layer.bounds.size.width, transformedFrame.size.height / self.webView.layer.bounds.size.height, 1.0) + self.webView.layer.animate(from: NSValue(caTransform3D: transform), to: NSValue(caTransform3D: self.webView.layer.transform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25) + } + + override func animateOut(to node: ASDisplayNode, completion: @escaping () -> Void) { + var transformedFrame = node.view.convert(node.view.bounds, to: self.webView) + let transformedSuperFrame = node.view.convert(node.view.bounds, to: self.webView.superview) + let transformedSelfFrame = node.view.convert(node.view.bounds, to: self.view) + let transformedCopyViewInitialFrame = self.webView.convert(self.webView.bounds, to: self.view) + + var positionCompleted = false + var boundsCompleted = false + var copyCompleted = false + + let copyView = node.view.snapshotContentTree()! + + self.view.insertSubview(copyView, belowSubview: self.webView) + copyView.frame = transformedSelfFrame + + let intermediateCompletion = { [weak copyView] in + if positionCompleted && boundsCompleted && copyCompleted { + copyView?.removeFromSuperview() + completion() + } + } + + copyView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, removeOnCompletion: false) + + copyView.layer.animatePosition(from: CGPoint(x: transformedCopyViewInitialFrame.midX, y: transformedCopyViewInitialFrame.midY), to: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + let scale = CGSize(width: transformedCopyViewInitialFrame.size.width / transformedSelfFrame.size.width, height: transformedCopyViewInitialFrame.size.height / transformedSelfFrame.size.height) + copyView.layer.animate(from: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), to: NSValue(caTransform3D: CATransform3DIdentity), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in + copyCompleted = true + intermediateCompletion() + }) + + self.webView.layer.animatePosition(from: self.webView.layer.position, to: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in + positionCompleted = true + intermediateCompletion() + }) + + self.webView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false) + + transformedFrame.origin = CGPoint() + + let transform = CATransform3DScale(self.webView.layer.transform, transformedFrame.size.width / self.webView.layer.bounds.size.width, transformedFrame.size.height / self.webView.layer.bounds.size.height, 1.0) + self.webView.layer.animate(from: NSValue(caTransform3D: self.webView.layer.transform), to: NSValue(caTransform3D: transform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in + boundsCompleted = true + intermediateCompletion() + }) + } } diff --git a/TelegramUI/ChatHistoryEntriesForView.swift b/TelegramUI/ChatHistoryEntriesForView.swift new file mode 100644 index 0000000000..40640ca1c3 --- /dev/null +++ b/TelegramUI/ChatHistoryEntriesForView.swift @@ -0,0 +1,35 @@ +import Foundation +import Postbox + +func chatHistoryEntriesForView(_ view: MessageHistoryView, includeUnreadEntry: Bool) -> [ChatHistoryEntry] { + var entries: [ChatHistoryEntry] = [] + + for entry in view.entries { + switch entry { + case let .HoleEntry(hole, _): + entries.append(.HoleEntry(hole)) + case let .MessageEntry(message, read, _): + entries.append(.MessageEntry(message, read)) + } + } + + if let maxReadIndex = view.maxReadIndex, includeUnreadEntry { + var inserted = false + var i = 0 + let unreadEntry: ChatHistoryEntry = .UnreadEntry(maxReadIndex) + for entry in entries { + if entry > unreadEntry { + entries.insert(unreadEntry, at: i) + inserted = true + + break + } + i += 1 + } + if !inserted { + //entries.append(.UnreadEntry(maxReadIndex)) + } + } + + return entries +} diff --git a/TelegramUI/ChatHistoryEntry.swift b/TelegramUI/ChatHistoryEntry.swift index cf639fc938..a36ef7e352 100644 --- a/TelegramUI/ChatHistoryEntry.swift +++ b/TelegramUI/ChatHistoryEntry.swift @@ -3,14 +3,14 @@ import TelegramCore enum ChatHistoryEntry: Identifiable, Comparable { case HoleEntry(MessageHistoryHole) - case MessageEntry(Message) + case MessageEntry(Message, Bool) case UnreadEntry(MessageIndex) var stableId: UInt64 { switch self { case let .HoleEntry(hole): return UInt64(hole.stableId) | ((UInt64(1) << 40)) - case let .MessageEntry(message): + case let .MessageEntry(message, _): return UInt64(message.stableId) | ((UInt64(2) << 40)) case .UnreadEntry: return UInt64(3) << 40 @@ -21,7 +21,7 @@ enum ChatHistoryEntry: Identifiable, Comparable { switch self { case let .HoleEntry(hole): return hole.maxIndex - case let .MessageEntry(message): + case let .MessageEntry(message, _): return MessageIndex(message) case let .UnreadEntry(index): return index @@ -38,20 +38,20 @@ func ==(lhs: ChatHistoryEntry, rhs: ChatHistoryEntry) -> Bool { default: return false } - case let .MessageEntry(lhsMessage): + case let .MessageEntry(lhsMessage, lhsRead): switch rhs { - case let .MessageEntry(rhsMessage) where MessageIndex(lhsMessage) == MessageIndex(rhsMessage) && lhsMessage.flags == rhsMessage.flags: - if lhsMessage.media.count != rhsMessage.media.count { - return false - } - for i in 0 ..< lhsMessage.media.count { - if !lhsMessage.media[i].isEqual(rhsMessage.media[i]) { + case let .MessageEntry(rhsMessage, rhsRead) where MessageIndex(lhsMessage) == MessageIndex(rhsMessage) && lhsMessage.flags == rhsMessage.flags && lhsRead == rhsRead: + if lhsMessage.media.count != rhsMessage.media.count { return false } - } - return true - default: - return false + for i in 0 ..< lhsMessage.media.count { + if !lhsMessage.media[i].isEqual(rhsMessage.media[i]) { + return false + } + } + return true + default: + return false } case let .UnreadEntry(lhsIndex): switch rhs { diff --git a/TelegramUI/ChatHistoryGridNode.swift b/TelegramUI/ChatHistoryGridNode.swift new file mode 100644 index 0000000000..b7ee37e69d --- /dev/null +++ b/TelegramUI/ChatHistoryGridNode.swift @@ -0,0 +1,305 @@ +import Foundation +import Postbox +import SwiftSignalKit +import Display +import AsyncDisplayKit +import TelegramCore + +struct ChatHistoryGridViewTransition { + let historyView: ChatHistoryView + let deleteItems: [Int] + let insertItems: [GridNodeInsertItem] + let updateItems: [GridNodeUpdateItem] + let scrollToItem: GridNodeScrollToItem? + let stationaryItemRange: (Int, Int)? +} + +private func mappedInsertEntries(account: Account, peerId: PeerId, controllerInteraction: ChatControllerInteraction, entries: [ChatHistoryViewTransitionInsertEntry]) -> [GridNodeInsertItem] { + return entries.map { entry -> GridNodeInsertItem in + switch entry.entry { + case let .MessageEntry(message, _): + return GridNodeInsertItem(index: entry.index, item: GridMessageItem(account: account, message: message, controllerInteraction: controllerInteraction), previousIndex: entry.previousIndex) + case .HoleEntry: + return GridNodeInsertItem(index: entry.index, item: GridHoleItem(), previousIndex: entry.previousIndex) + case .UnreadEntry: + assertionFailure() + return GridNodeInsertItem(index: entry.index, item: GridHoleItem(), previousIndex: entry.previousIndex) + } + } +} + +private func mappedUpdateEntries(account: Account, peerId: PeerId, controllerInteraction: ChatControllerInteraction, entries: [ChatHistoryViewTransitionUpdateEntry]) -> [GridNodeUpdateItem] { + return entries.map { entry -> GridNodeUpdateItem in + switch entry.entry { + case let .MessageEntry(message, _): + return GridNodeUpdateItem(index: entry.index, item: GridMessageItem(account: account, message: message, controllerInteraction: controllerInteraction)) + case .HoleEntry: + return GridNodeUpdateItem(index: entry.index, item: GridHoleItem()) + case .UnreadEntry: + assertionFailure() + return GridNodeUpdateItem(index: entry.index, item: GridHoleItem()) + } + } +} + +private func mappedChatHistoryViewListTransition(account: Account, peerId: PeerId, controllerInteraction: ChatControllerInteraction, transition: ChatHistoryViewTransition) -> ChatHistoryGridViewTransition { + var mappedScrollToItem: GridNodeScrollToItem? + if let scrollToItem = transition.scrollToItem { + let mappedPosition: GridNodeScrollToItemPosition + switch scrollToItem.position { + case .Top: + mappedPosition = .top + case .Center: + mappedPosition = .center + case .Bottom: + mappedPosition = .bottom + } + mappedScrollToItem = GridNodeScrollToItem(index: scrollToItem.index, position: mappedPosition) + } + + return ChatHistoryGridViewTransition(historyView: transition.historyView, deleteItems: transition.deleteItems.map { $0.index }, insertItems: mappedInsertEntries(account: account, peerId: peerId, controllerInteraction: controllerInteraction, entries: transition.insertEntries), updateItems: mappedUpdateEntries(account: account, peerId: peerId, controllerInteraction: controllerInteraction, entries: transition.updateEntries), scrollToItem: mappedScrollToItem, stationaryItemRange: transition.stationaryItemRange) +} + +private func itemSizeForContainerLayout(size: CGSize) -> CGSize { + let side = floor(size.width / 4.0) + return CGSize(width: side, height: side) +} + +public final class ChatHistoryGridNode: GridNode, ChatHistoryNode { + private let account: Account + private let peerId: PeerId + private let messageId: MessageId? + private let tagMask: MessageTags? + + private var historyView: ChatHistoryView? + + private let historyDisposable = MetaDisposable() + + private let messageViewQueue = Queue() + + private var dequeuedInitialTransitionOnLayout = false + private var enqueuedHistoryViewTransition: (ChatHistoryGridViewTransition, () -> Void)? + var layoutActionOnViewTransition: ((ChatHistoryGridViewTransition) -> (ChatHistoryGridViewTransition, ListViewUpdateSizeAndInsets?))? + + public let historyReady = Promise() + private var didSetHistoryReady = false + + public var preloadPages: Bool = true { + didSet { + if self.preloadPages != oldValue { + + } + } + } + + private let _chatHistoryLocation = Promise() + private var chatHistoryLocation: Signal { + return self._chatHistoryLocation.get() + } + + private let galleryHiddenMesageAndMediaDisposable = MetaDisposable() + + public init(account: Account, peerId: PeerId, messageId: MessageId?, tagMask: MessageTags?, controllerInteraction: ChatControllerInteraction) { + self.account = account + self.peerId = peerId + self.messageId = messageId + self.tagMask = tagMask + + super.init() + + //self.preloadPages = false + + let messageViewQueue = self.messageViewQueue + + let historyViewUpdate = self.chatHistoryLocation + |> distinctUntilChanged + |> mapToSignal { location in + return chatHistoryViewForLocation(location, account: account, peerId: peerId, fixedCombinedReadState: nil, tagMask: tagMask) + } + + let previousView = Atomic(value: nil) + + let historyViewTransition = historyViewUpdate |> mapToQueue { [weak self] update -> Signal in + switch update { + case .Loading: + Queue.mainQueue().async { [weak self] in + if let strongSelf = self { + if !strongSelf.didSetHistoryReady { + strongSelf.didSetHistoryReady = true + strongSelf.historyReady.set(.single(true)) + } + } + } + return .complete() + case let .HistoryView(view, type, scrollPosition): + let reason: ChatHistoryViewTransitionReason + var prepareOnMainQueue = false + switch type { + case let .Initial(fadeIn): + reason = ChatHistoryViewTransitionReason.Initial(fadeIn: fadeIn) + prepareOnMainQueue = !fadeIn + case let .Generic(genericType): + switch genericType { + case .InitialUnread: + reason = ChatHistoryViewTransitionReason.Initial(fadeIn: false) + case .Generic: + reason = ChatHistoryViewTransitionReason.InteractiveChanges + case .UpdateVisible: + reason = ChatHistoryViewTransitionReason.Reload + case let .FillHole(insertions, deletions): + reason = ChatHistoryViewTransitionReason.HoleChanges(filledHoleDirections: insertions, removeHoleDirections: deletions) + } + } + + let processedView = ChatHistoryView(originalView: view, filteredEntries: chatHistoryEntriesForView(view, includeUnreadEntry: false)) + let previous = previousView.swap(processedView) + + return preparedChatHistoryViewTransition(from: previous, to: processedView, reason: reason, account: account, peerId: peerId, controllerInteraction: controllerInteraction, scrollPosition: scrollPosition) |> map({ mappedChatHistoryViewListTransition(account: account, peerId: peerId, controllerInteraction: controllerInteraction, transition: $0) }) |> runOn(prepareOnMainQueue ? Queue.mainQueue() : messageViewQueue) + } + } + + let appliedTransition = historyViewTransition |> deliverOnMainQueue |> mapToQueue { [weak self] transition -> Signal in + if let strongSelf = self { + return strongSelf.enqueueHistoryViewTransition(transition) + } + return .complete() + } + + self.historyDisposable.set(appliedTransition.start()) + + if let messageId = messageId { + self._chatHistoryLocation.set(.single(ChatHistoryLocation.InitialSearch(messageId: messageId, count: 100))) + } else { + self._chatHistoryLocation.set(.single(ChatHistoryLocation.Initial(count: 100))) + } + + /*self.displayedItemRangeChanged = { [weak self] displayedRange in + if let strongSelf = self { + /*if let transactionTag = strongSelf.listViewTransactionTag { + strongSelf.messageViewQueue.dispatch { + if transactionTag == strongSelf.historyViewTransactionTag { + if let range = range, historyView = strongSelf.historyView, firstEntry = historyView.filteredEntries.first, lastEntry = historyView.filteredEntries.last { + if range.firstIndex < 5 && historyView.originalView.laterId != nil { + strongSelf._chatHistoryLocation.set(.single(ChatHistoryLocation.Navigation(index: lastEntry.index, anchorIndex: historyView.originalView.anchorIndex))) + } else if range.lastIndex >= historyView.filteredEntries.count - 5 && historyView.originalView.earlierId != nil { + strongSelf._chatHistoryLocation.set(.single(ChatHistoryLocation.Navigation(index: firstEntry.index, anchorIndex: historyView.originalView.anchorIndex))) + } else { + //strongSelf.account.postbox.updateMessageHistoryViewVisibleRange(messageView.id, earliestVisibleIndex: viewEntries[viewEntries.count - 1 - range.lastIndex].index, latestVisibleIndex: viewEntries[viewEntries.count - 1 - range.firstIndex].index) + } + } + } + } + }*/ + + if let visible = displayedRange.visibleRange, let historyView = strongSelf.historyView { + if let messageId = maxIncomingMessageIdForEntries(historyView.filteredEntries, indexRange: (historyView.filteredEntries.count - 1 - visible.lastIndex, historyView.filteredEntries.count - 1 - visible.firstIndex)) { + strongSelf.updateMaxVisibleReadIncomingMessageId(messageId) + } + } + } + }*/ + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.historyDisposable.dispose() + } + + public func scrollToStartOfHistory() { + self._chatHistoryLocation.set(.single(ChatHistoryLocation.Scroll(index: MessageIndex.lowerBound(peerId: self.peerId), anchorIndex: MessageIndex.lowerBound(peerId: self.peerId), sourceIndex: MessageIndex.upperBound(peerId: self.peerId), scrollPosition: .Bottom, animated: true))) + } + + public func scrollToEndOfHistory() { + self._chatHistoryLocation.set(.single(ChatHistoryLocation.Scroll(index: MessageIndex.upperBound(peerId: self.peerId), anchorIndex: MessageIndex.upperBound(peerId: self.peerId), sourceIndex: MessageIndex.lowerBound(peerId: self.peerId), scrollPosition: .Top, animated: true))) + } + + public func scrollToMessage(from fromIndex: MessageIndex, to toIndex: MessageIndex) { + self._chatHistoryLocation.set(.single(ChatHistoryLocation.Scroll(index: toIndex, anchorIndex: toIndex, sourceIndex: fromIndex, scrollPosition: .Center(.Bottom), animated: true))) + } + + public func messageInCurrentHistoryView(_ id: MessageId) -> Message? { + if let historyView = self.historyView { + var galleryMedia: Media? + for case let .MessageEntry(message, _) in historyView.filteredEntries where message.id == id { + return message + } + } + return nil + } + + private func enqueueHistoryViewTransition(_ transition: ChatHistoryGridViewTransition) -> Signal { + return Signal { [weak self] subscriber in + if let strongSelf = self { + if let _ = strongSelf.enqueuedHistoryViewTransition { + preconditionFailure() + } + + strongSelf.enqueuedHistoryViewTransition = (transition, { + subscriber.putCompletion() + }) + + if strongSelf.isNodeLoaded { + strongSelf.dequeueHistoryViewTransition() + } else { + if !strongSelf.didSetHistoryReady { + strongSelf.didSetHistoryReady = true + strongSelf.historyReady.set(.single(true)) + } + } + } else { + subscriber.putCompletion() + } + + return EmptyDisposable + } |> runOn(Queue.mainQueue()) + } + + private func dequeueHistoryViewTransition() { + if let (transition, completion) = self.enqueuedHistoryViewTransition { + self.enqueuedHistoryViewTransition = nil + + let completion: (GridNodeDisplayedItemRange) -> Void = { [weak self] visibleRange in + if let strongSelf = self { + strongSelf.historyView = transition.historyView + + if let range = visibleRange.loadedRange { + strongSelf.account.postbox.updateMessageHistoryViewVisibleRange(transition.historyView.originalView.id, earliestVisibleIndex: transition.historyView.filteredEntries[transition.historyView.filteredEntries.count - 1 - range.upperBound].index, latestVisibleIndex: transition.historyView.filteredEntries[transition.historyView.filteredEntries.count - 1 - range.lowerBound].index) + } + + if !strongSelf.didSetHistoryReady { + strongSelf.didSetHistoryReady = true + strongSelf.historyReady.set(.single(true)) + } + + completion() + } + } + + if let layoutActionOnViewTransition = self.layoutActionOnViewTransition { + self.layoutActionOnViewTransition = nil + let (mappedTransition, updateSizeAndInsets) = layoutActionOnViewTransition(transition) + + var updateLayout: GridNodeUpdateLayout? + if let updateSizeAndInsets = updateSizeAndInsets { + updateLayout = GridNodeUpdateLayout(layout: GridNodeLayout(size: updateSizeAndInsets.size, insets: updateSizeAndInsets.insets, preloadSize: 400.0, itemSize: CGSize(width: 200.0, height: 200.0), indexOffset: 0), transition: .immediate) + } + + self.transaction(GridNodeTransaction(deleteItems: mappedTransition.deleteItems, insertItems: mappedTransition.insertItems, updateItems: mappedTransition.updateItems, scrollToItem: mappedTransition.scrollToItem, updateLayout: updateLayout, stationaryItemRange: mappedTransition.stationaryItemRange), completion: completion) + } else { + self.transaction(GridNodeTransaction(deleteItems: transition.deleteItems, insertItems: transition.insertItems, updateItems: transition.updateItems, scrollToItem: transition.scrollToItem, updateLayout: nil, stationaryItemRange: transition.stationaryItemRange), completion: completion) + } + } + } + + public func updateLayout(transition: ContainedViewLayoutTransition, updateSizeAndInsets: ListViewUpdateSizeAndInsets) { + self.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: updateSizeAndInsets.size, insets: updateSizeAndInsets.insets, preloadSize: 400.0, itemSize: itemSizeForContainerLayout(size: updateSizeAndInsets.size), indexOffset: 0), transition: .immediate), stationaryItemRange: nil), completion: { _ in }) + + if !self.dequeuedInitialTransitionOnLayout { + self.dequeuedInitialTransitionOnLayout = true + self.dequeueHistoryViewTransition() + } + } +} diff --git a/TelegramUI/ChatHistoryListNode.swift b/TelegramUI/ChatHistoryListNode.swift new file mode 100644 index 0000000000..a614db0374 --- /dev/null +++ b/TelegramUI/ChatHistoryListNode.swift @@ -0,0 +1,402 @@ +import Foundation +import Postbox +import SwiftSignalKit +import Display +import AsyncDisplayKit +import TelegramCore + +public enum ChatHistoryListMode { + case bubbles + case list +} + +enum ChatHistoryViewScrollPosition { + case Unread(index: MessageIndex) + case Index(index: MessageIndex, position: ListViewScrollPosition, directionHint: ListViewScrollToItemDirectionHint, animated: Bool) +} + +enum ChatHistoryViewUpdateType { + case Initial(fadeIn: Bool) + case Generic(type: ViewUpdateType) +} + +enum ChatHistoryViewUpdate { + case Loading + case HistoryView(view: MessageHistoryView, type: ChatHistoryViewUpdateType, scrollPosition: ChatHistoryViewScrollPosition?) +} + +struct ChatHistoryView { + let originalView: MessageHistoryView + let filteredEntries: [ChatHistoryEntry] +} + +enum ChatHistoryViewTransitionReason { + case Initial(fadeIn: Bool) + case InteractiveChanges + case HoleChanges(filledHoleDirections: [MessageIndex: HoleFillDirection], removeHoleDirections: [MessageIndex: HoleFillDirection]) + case Reload +} + +struct ChatHistoryViewTransitionInsertEntry { + let index: Int + let previousIndex: Int? + let entry: ChatHistoryEntry + let directionHint: ListViewItemOperationDirectionHint? +} + +struct ChatHistoryViewTransitionUpdateEntry { + let index: Int + let previousIndex: Int + let entry: ChatHistoryEntry + let directionHint: ListViewItemOperationDirectionHint? +} + +struct ChatHistoryViewTransition { + let historyView: ChatHistoryView + let deleteItems: [ListViewDeleteItem] + let insertEntries: [ChatHistoryViewTransitionInsertEntry] + let updateEntries: [ChatHistoryViewTransitionUpdateEntry] + let options: ListViewDeleteAndInsertOptions + let scrollToItem: ListViewScrollToItem? + let stationaryItemRange: (Int, Int)? +} + +struct ChatHistoryListViewTransition { + let historyView: ChatHistoryView + let deleteItems: [ListViewDeleteItem] + let insertItems: [ListViewInsertItem] + let updateItems: [ListViewUpdateItem] + let options: ListViewDeleteAndInsertOptions + let scrollToItem: ListViewScrollToItem? + let stationaryItemRange: (Int, Int)? +} + +private func maxIncomingMessageIdForEntries(_ entries: [ChatHistoryEntry], indexRange: (Int, Int)) -> MessageId? { + for i in (indexRange.0 ... indexRange.1).reversed() { + if case let .MessageEntry(message, _) = entries[i], message.flags.contains(.Incoming) { + return message.id + } + } + return nil +} + +private func mappedInsertEntries(account: Account, peerId: PeerId, controllerInteraction: ChatControllerInteraction, mode: ChatHistoryListMode, entries: [ChatHistoryViewTransitionInsertEntry]) -> [ListViewInsertItem] { + return entries.map { entry -> ListViewInsertItem in + switch entry.entry { + case let .MessageEntry(message, read): + let item: ListViewItem + switch mode { + case .bubbles: + item = ChatMessageItem(account: account, peerId: peerId, controllerInteraction: controllerInteraction, message: message, read: read) + case .list: + item = ListMessageItem(account: account, peerId: peerId, controllerInteraction: controllerInteraction, message: message) + } + return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: item, directionHint: entry.directionHint) + case .HoleEntry: + return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatHoleItem(), directionHint: entry.directionHint) + case .UnreadEntry: + return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatUnreadItem(), directionHint: entry.directionHint) + } + } +} + +private func mappedUpdateEntries(account: Account, peerId: PeerId, controllerInteraction: ChatControllerInteraction, mode: ChatHistoryListMode, entries: [ChatHistoryViewTransitionUpdateEntry]) -> [ListViewUpdateItem] { + return entries.map { entry -> ListViewUpdateItem in + switch entry.entry { + case let .MessageEntry(message, read): + let item: ListViewItem + switch mode { + case .bubbles: + item = ChatMessageItem(account: account, peerId: peerId, controllerInteraction: controllerInteraction, message: message, read: read) + case .list: + item = ListMessageItem(account: account, peerId: peerId, controllerInteraction: controllerInteraction, message: message) + } + return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: item, directionHint: entry.directionHint) + case .HoleEntry: + return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatHoleItem(), directionHint: entry.directionHint) + case .UnreadEntry: + return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatUnreadItem(), directionHint: entry.directionHint) + } + } +} + +private func mappedChatHistoryViewListTransition(account: Account, peerId: PeerId, controllerInteraction: ChatControllerInteraction, mode: ChatHistoryListMode, transition: ChatHistoryViewTransition) -> ChatHistoryListViewTransition { + return ChatHistoryListViewTransition(historyView: transition.historyView, deleteItems: transition.deleteItems, insertItems: mappedInsertEntries(account: account, peerId: peerId, controllerInteraction: controllerInteraction, mode: mode, entries: transition.insertEntries), updateItems: mappedUpdateEntries(account: account, peerId: peerId, controllerInteraction: controllerInteraction, mode: mode, entries: transition.updateEntries), options: transition.options, scrollToItem: transition.scrollToItem, stationaryItemRange: transition.stationaryItemRange) +} + +public final class ChatHistoryListNode: ListView, ChatHistoryNode { + private let account: Account + private let peerId: PeerId + private let messageId: MessageId? + private let controllerInteraction: ChatControllerInteraction + private let mode: ChatHistoryListMode + + private var historyView: ChatHistoryView? + + private let historyDisposable = MetaDisposable() + private let readHistoryDisposable = MetaDisposable() + + private let messageViewQueue = Queue() + + private var dequeuedInitialTransitionOnLayout = false + private var enqueuedHistoryViewTransition: (ChatHistoryListViewTransition, () -> Void)? + var layoutActionOnViewTransition: ((ChatHistoryListViewTransition) -> (ChatHistoryListViewTransition, ListViewUpdateSizeAndInsets?))? + + public let historyReady = Promise() + private var didSetHistoryReady = false + + private let maxVisibleIncomingMessageId = Promise() + let canReadHistory = Promise() + + private let _chatHistoryLocation = Promise() + private var chatHistoryLocation: Signal { + return self._chatHistoryLocation.get() + } + + private let galleryHiddenMesageAndMediaDisposable = MetaDisposable() + + public init(account: Account, peerId: PeerId, tagMask: MessageTags?, messageId: MessageId?, controllerInteraction: ChatControllerInteraction, mode: ChatHistoryListMode = .bubbles) { + self.account = account + self.peerId = peerId + self.messageId = messageId + self.controllerInteraction = controllerInteraction + self.mode = mode + + super.init() + + self.preloadPages = false + switch self.mode { + case .bubbles: + self.transform = CATransform3DMakeRotation(CGFloat(M_PI), 0.0, 0.0, 1.0) + case .list: + break + } + + let messageViewQueue = self.messageViewQueue + + let fixedCombinedReadState = Atomic(value: nil) + + let historyViewUpdate = self.chatHistoryLocation + |> distinctUntilChanged + |> mapToSignal { location in + return chatHistoryViewForLocation(location, account: account, peerId: peerId, fixedCombinedReadState: fixedCombinedReadState.with { $0 }, tagMask: tagMask) |> beforeNext { viewUpdate in + switch viewUpdate { + case let .HistoryView(view, _, _): + let _ = fixedCombinedReadState.swap(view.combinedReadState) + default: + break + } + } + } + + let previousView = Atomic(value: nil) + + let historyViewTransition = historyViewUpdate |> mapToQueue { [weak self] update -> Signal in + switch update { + case .Loading: + Queue.mainQueue().async { [weak self] in + if let strongSelf = self { + if !strongSelf.didSetHistoryReady { + strongSelf.didSetHistoryReady = true + strongSelf.historyReady.set(.single(true)) + } + } + } + return .complete() + case let .HistoryView(view, type, scrollPosition): + let reason: ChatHistoryViewTransitionReason + var prepareOnMainQueue = false + switch type { + case let .Initial(fadeIn): + reason = ChatHistoryViewTransitionReason.Initial(fadeIn: fadeIn) + prepareOnMainQueue = !fadeIn + case let .Generic(genericType): + switch genericType { + case .InitialUnread: + reason = ChatHistoryViewTransitionReason.Initial(fadeIn: false) + case .Generic: + reason = ChatHistoryViewTransitionReason.InteractiveChanges + case .UpdateVisible: + reason = ChatHistoryViewTransitionReason.Reload + case let .FillHole(insertions, deletions): + reason = ChatHistoryViewTransitionReason.HoleChanges(filledHoleDirections: insertions, removeHoleDirections: deletions) + } + } + + let processedView = ChatHistoryView(originalView: view, filteredEntries: chatHistoryEntriesForView(view, includeUnreadEntry: mode == .bubbles)) + let previous = previousView.swap(processedView) + + return preparedChatHistoryViewTransition(from: previous, to: processedView, reason: reason, account: account, peerId: peerId, controllerInteraction: controllerInteraction, scrollPosition: scrollPosition) |> map({ mappedChatHistoryViewListTransition(account: account, peerId: peerId, controllerInteraction: controllerInteraction, mode: mode, transition: $0) }) |> runOn(prepareOnMainQueue ? Queue.mainQueue() : messageViewQueue) + } + } + + let appliedTransition = historyViewTransition |> deliverOnMainQueue |> mapToQueue { [weak self] transition -> Signal in + if let strongSelf = self { + return strongSelf.enqueueHistoryViewTransition(transition) + } + return .complete() + } + + self.historyDisposable.set(appliedTransition.start()) + + let previousMaxIncomingMessageId = Atomic(value: nil) + let readHistory = combineLatest(self.maxVisibleIncomingMessageId.get(), self.canReadHistory.get()) + |> map { messageId, canRead in + if canRead { + var apply = false + let _ = previousMaxIncomingMessageId.modify { previousId in + if previousId == nil || previousId! < messageId { + apply = true + return messageId + } else { + return previousId + } + } + if apply { + let _ = account.postbox.modify({ modifier in + modifier.applyInteractiveReadMaxId(messageId) + }).start() + } + } + } + + self.readHistoryDisposable.set(readHistory.start()) + + if let messageId = messageId { + self._chatHistoryLocation.set(.single(ChatHistoryLocation.InitialSearch(messageId: messageId, count: 60))) + } else { + self._chatHistoryLocation.set(.single(ChatHistoryLocation.Initial(count: 60))) + } + + self.displayedItemRangeChanged = { [weak self] displayedRange in + if let strongSelf = self { + /*if let transactionTag = strongSelf.listViewTransactionTag { + strongSelf.messageViewQueue.dispatch { + if transactionTag == strongSelf.historyViewTransactionTag { + if let range = range, historyView = strongSelf.historyView, firstEntry = historyView.filteredEntries.first, lastEntry = historyView.filteredEntries.last { + if range.firstIndex < 5 && historyView.originalView.laterId != nil { + strongSelf._chatHistoryLocation.set(.single(ChatHistoryLocation.Navigation(index: lastEntry.index, anchorIndex: historyView.originalView.anchorIndex))) + } else if range.lastIndex >= historyView.filteredEntries.count - 5 && historyView.originalView.earlierId != nil { + strongSelf._chatHistoryLocation.set(.single(ChatHistoryLocation.Navigation(index: firstEntry.index, anchorIndex: historyView.originalView.anchorIndex))) + } else { + //strongSelf.account.postbox.updateMessageHistoryViewVisibleRange(messageView.id, earliestVisibleIndex: viewEntries[viewEntries.count - 1 - range.lastIndex].index, latestVisibleIndex: viewEntries[viewEntries.count - 1 - range.firstIndex].index) + } + } + } + } + }*/ + + if let visible = displayedRange.visibleRange, let historyView = strongSelf.historyView { + if let messageId = maxIncomingMessageIdForEntries(historyView.filteredEntries, indexRange: (historyView.filteredEntries.count - 1 - visible.lastIndex, historyView.filteredEntries.count - 1 - visible.firstIndex)) { + strongSelf.updateMaxVisibleReadIncomingMessageId(messageId) + } + } + } + } + } + + deinit { + self.historyDisposable.dispose() + self.readHistoryDisposable.dispose() + } + + public func scrollToStartOfHistory() { + self._chatHistoryLocation.set(.single(ChatHistoryLocation.Scroll(index: MessageIndex.lowerBound(peerId: self.peerId), anchorIndex: MessageIndex.lowerBound(peerId: self.peerId), sourceIndex: MessageIndex.upperBound(peerId: self.peerId), scrollPosition: .Bottom, animated: true))) + } + + public func scrollToEndOfHistory() { + self._chatHistoryLocation.set(.single(ChatHistoryLocation.Scroll(index: MessageIndex.upperBound(peerId: self.peerId), anchorIndex: MessageIndex.upperBound(peerId: self.peerId), sourceIndex: MessageIndex.lowerBound(peerId: self.peerId), scrollPosition: .Top, animated: true))) + } + + public func scrollToMessage(from fromIndex: MessageIndex, to toIndex: MessageIndex) { + self._chatHistoryLocation.set(.single(ChatHistoryLocation.Scroll(index: toIndex, anchorIndex: toIndex, sourceIndex: fromIndex, scrollPosition: .Center(.Bottom), animated: true))) + } + + public func messageInCurrentHistoryView(_ id: MessageId) -> Message? { + if let historyView = self.historyView { + var galleryMedia: Media? + for case let .MessageEntry(message, _) in historyView.filteredEntries where message.id == id { + return message + } + } + return nil + } + + private func updateMaxVisibleReadIncomingMessageId(_ id: MessageId) { + self.maxVisibleIncomingMessageId.set(.single(id)) + } + + private func enqueueHistoryViewTransition(_ transition: ChatHistoryListViewTransition) -> Signal { + return Signal { [weak self] subscriber in + if let strongSelf = self { + if let _ = strongSelf.enqueuedHistoryViewTransition { + preconditionFailure() + } + + strongSelf.enqueuedHistoryViewTransition = (transition, { + subscriber.putCompletion() + }) + + if strongSelf.isNodeLoaded { + strongSelf.dequeueHistoryViewTransition() + } else { + if !strongSelf.didSetHistoryReady { + strongSelf.didSetHistoryReady = true + strongSelf.historyReady.set(.single(true)) + } + } + } else { + subscriber.putCompletion() + } + + return EmptyDisposable + } |> runOn(Queue.mainQueue()) + } + + private func dequeueHistoryViewTransition() { + if let (transition, completion) = self.enqueuedHistoryViewTransition { + self.enqueuedHistoryViewTransition = nil + + let completion: (ListViewDisplayedItemRange) -> Void = { [weak self] visibleRange in + if let strongSelf = self { + strongSelf.historyView = transition.historyView + + if let range = visibleRange.loadedRange { + strongSelf.account.postbox.updateMessageHistoryViewVisibleRange(transition.historyView.originalView.id, earliestVisibleIndex: transition.historyView.filteredEntries[transition.historyView.filteredEntries.count - 1 - range.lastIndex].index, latestVisibleIndex: transition.historyView.filteredEntries[transition.historyView.filteredEntries.count - 1 - range.firstIndex].index) + + if let visible = visibleRange.visibleRange { + if let messageId = maxIncomingMessageIdForEntries(transition.historyView.filteredEntries, indexRange: (transition.historyView.filteredEntries.count - 1 - visible.lastIndex, transition.historyView.filteredEntries.count - 1 - visible.firstIndex)) { + strongSelf.updateMaxVisibleReadIncomingMessageId(messageId) + } + } + } + + if !strongSelf.didSetHistoryReady { + strongSelf.didSetHistoryReady = true + strongSelf.historyReady.set(.single(true)) + } + + completion() + } + } + + if let layoutActionOnViewTransition = self.layoutActionOnViewTransition { + self.layoutActionOnViewTransition = nil + let (mappedTransition, updateSizeAndInsets) = layoutActionOnViewTransition(transition) + + self.deleteAndInsertItems(deleteIndices: mappedTransition.deleteItems, insertIndicesAndItems: transition.insertItems, updateIndicesAndItems: transition.updateItems, options: mappedTransition.options, scrollToItem: mappedTransition.scrollToItem, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: mappedTransition.stationaryItemRange, completion: completion) + } else { + self.deleteAndInsertItems(deleteIndices: transition.deleteItems, insertIndicesAndItems: transition.insertItems, updateIndicesAndItems: transition.updateItems, options: transition.options, scrollToItem: transition.scrollToItem, stationaryItemRange: transition.stationaryItemRange, completion: completion) + } + } + } + + public func updateLayout(transition: ContainedViewLayoutTransition, updateSizeAndInsets: ListViewUpdateSizeAndInsets) { + self.deleteAndInsertItems(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, completion: { _ in }) + + if !self.dequeuedInitialTransitionOnLayout { + self.dequeuedInitialTransitionOnLayout = true + self.dequeueHistoryViewTransition() + } + } +} diff --git a/TelegramUI/ChatHistoryNode.swift b/TelegramUI/ChatHistoryNode.swift new file mode 100644 index 0000000000..64d720b59d --- /dev/null +++ b/TelegramUI/ChatHistoryNode.swift @@ -0,0 +1,13 @@ +import Foundation +import Postbox +import SwiftSignalKit +import Display + +public protocol ChatHistoryNode: class { + var historyReady: Promise { get } + var preloadPages: Bool { get set } + + func messageInCurrentHistoryView(_ id: MessageId) -> Message? + func updateLayout(transition: ContainedViewLayoutTransition, updateSizeAndInsets: ListViewUpdateSizeAndInsets) + func forEachItemNode(_ f: @noescape(ASDisplayNode) -> Void) +} diff --git a/TelegramUI/ChatHistoryViewForLocation.swift b/TelegramUI/ChatHistoryViewForLocation.swift new file mode 100644 index 0000000000..3148578f6d --- /dev/null +++ b/TelegramUI/ChatHistoryViewForLocation.swift @@ -0,0 +1,109 @@ +import Foundation +import Postbox +import TelegramCore +import SwiftSignalKit +import Display + +func chatHistoryViewForLocation(_ location: ChatHistoryLocation, account: Account, peerId: PeerId, fixedCombinedReadState: CombinedPeerReadState?, tagMask: MessageTags?) -> Signal { + switch location { + case let .Initial(count): + var preloaded = false + var fadeIn = false + let signal: Signal<(MessageHistoryView, ViewUpdateType), NoError> + if let tagMask = tagMask { + signal = account.viewTracker.aroundMessageHistoryViewForPeerId(peerId, index: MessageIndex.upperBound(peerId: peerId), count: count, anchorIndex: MessageIndex.upperBound(peerId: peerId), fixedCombinedReadState: nil, tagMask: tagMask) + } else { + signal = account.viewTracker.aroundUnreadMessageHistoryViewForPeerId(peerId, count: count, tagMask: tagMask) + } + return signal |> map { view, updateType -> ChatHistoryViewUpdate in + if preloaded { + return .HistoryView(view: view, type: .Generic(type: updateType), scrollPosition: nil) + } else { + if let maxReadIndex = view.maxReadIndex { + var targetIndex = 0 + for i in 0 ..< view.entries.count { + if view.entries[i].index >= maxReadIndex { + targetIndex = i + break + } + } + + let maxIndex = min(view.entries.count, targetIndex + count / 2) + if maxIndex >= targetIndex { + for i in targetIndex ..< maxIndex { + if case .HoleEntry = view.entries[i] { + fadeIn = true + return .Loading + } + } + } + + preloaded = true + return .HistoryView(view: view, type: .Initial(fadeIn: fadeIn), scrollPosition: .Unread(index: maxReadIndex)) + } else { + preloaded = true + return .HistoryView(view: view, type: .Initial(fadeIn: fadeIn), scrollPosition: nil) + } + } + } + case let .InitialSearch(messageId, count): + var preloaded = false + var fadeIn = false + return account.viewTracker.aroundIdMessageHistoryViewForPeerId(peerId, count: count, messageId: messageId, tagMask: tagMask) |> map { view, updateType -> ChatHistoryViewUpdate in + if preloaded { + return .HistoryView(view: view, type: .Generic(type: updateType), scrollPosition: nil) + } else { + let anchorIndex = view.anchorIndex + + var targetIndex = 0 + for i in 0 ..< view.entries.count { + if view.entries[i].index >= anchorIndex { + targetIndex = i + break + } + } + + let maxIndex = min(view.entries.count, targetIndex + count / 2) + if maxIndex >= targetIndex { + for i in targetIndex ..< maxIndex { + if case .HoleEntry = view.entries[i] { + fadeIn = true + return .Loading + } + } + } + + preloaded = true + //case Index(index: MessageIndex, position: ListViewScrollPosition, directionHint: ListViewScrollToItemDirectionHint, animated: Bool) + return .HistoryView(view: view, type: .Initial(fadeIn: fadeIn), scrollPosition: .Index(index: anchorIndex, position: .Center(.Bottom), directionHint: .Down, animated: false)) + } + } + case let .Navigation(index, anchorIndex): + var first = true + return account.viewTracker.aroundMessageHistoryViewForPeerId(peerId, index: index, count: 140, anchorIndex: anchorIndex, fixedCombinedReadState: fixedCombinedReadState, tagMask: tagMask) |> map { view, updateType -> ChatHistoryViewUpdate in + let genericType: ViewUpdateType + if first { + first = false + genericType = ViewUpdateType.UpdateVisible + } else { + genericType = updateType + } + return .HistoryView(view: view, type: .Generic(type: genericType), scrollPosition: nil) + } + case let .Scroll(index, anchorIndex, sourceIndex, scrollPosition, animated): + let directionHint: ListViewScrollToItemDirectionHint = sourceIndex > index ? .Down : .Up + let chatScrollPosition = ChatHistoryViewScrollPosition.Index(index: index, position: scrollPosition, directionHint: directionHint, animated: animated) + var first = true + return account.viewTracker.aroundMessageHistoryViewForPeerId(peerId, index: index, count: 140, anchorIndex: anchorIndex, fixedCombinedReadState: fixedCombinedReadState, tagMask: tagMask) |> map { view, updateType -> ChatHistoryViewUpdate in + let genericType: ViewUpdateType + let scrollPosition: ChatHistoryViewScrollPosition? = first ? chatScrollPosition : nil + if first { + first = false + genericType = ViewUpdateType.UpdateVisible + } else { + genericType = updateType + } + return .HistoryView(view: view, type: .Generic(type: genericType), scrollPosition: scrollPosition) + } + } +} diff --git a/TelegramUI/ChatHoleItem.swift b/TelegramUI/ChatHoleItem.swift index a71a6ee03d..540dfc4fa9 100644 --- a/TelegramUI/ChatHoleItem.swift +++ b/TelegramUI/ChatHoleItem.swift @@ -40,7 +40,7 @@ class ChatHoleItemNode: ListViewItemNode { super.init(layerBacked: true) - self.backgroundNode.image = backgroundImage(color: UIColor.blue) + self.backgroundNode.image = backgroundImage(color: UIColor(0x1195f2)) self.addSubnode(self.backgroundNode) self.addSubnode(self.labelNode) diff --git a/TelegramUI/ChatImageGalleryItem.swift b/TelegramUI/ChatImageGalleryItem.swift index e6f06e360e..4461f256e7 100644 --- a/TelegramUI/ChatImageGalleryItem.swift +++ b/TelegramUI/ChatImageGalleryItem.swift @@ -120,16 +120,6 @@ final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode { let transformedSelfFrame = node.view.convert(node.view.bounds, to: self.view) let transformedCopyViewFinalFrame = self.imageNode.view.convert(self.imageNode.view.bounds, to: self.view) - /*let image = generateImage(node.view.bounds.size, contextGenerator: { size, context in - context.clear(CGRect(origin: CGPoint(), size: size)) - context.translate(x: size.width / 2.0, y: size.height / 2.0) - context.scale(x: 1.0, y: -1.0) - context.translate(x: -size.width / 2.0, y: -size.height / 2.0) - //node.view.drawHierarchy(in: CGRect(origin: CGPoint(), size: size), afterScreenUpdates: false) - node.layer.render(in: context) - })*/ - - //let copyView = UIImageView(image: image) let copyView = node.view.snapshotContentTree()! self.view.insertSubview(copyView, belowSubview: self.scrollView) @@ -139,7 +129,6 @@ final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode { copyView?.removeFromSuperview() }) - //copyView.layer.animateFrame(from: transformedSelfFrame, to: transformedCopyViewFinalFrame, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) copyView.layer.animatePosition(from: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY), to: CGPoint(x: transformedCopyViewFinalFrame.midX, y: transformedCopyViewFinalFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) let scale = CGSize(width: transformedCopyViewFinalFrame.size.width / transformedSelfFrame.size.width, height: transformedCopyViewFinalFrame.size.height / transformedSelfFrame.size.height) copyView.layer.animate(from: NSValue(caTransform3D: CATransform3DIdentity), to: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false) diff --git a/TelegramUI/ChatInfo.swift b/TelegramUI/ChatInfo.swift new file mode 100644 index 0000000000..67263c82fa --- /dev/null +++ b/TelegramUI/ChatInfo.swift @@ -0,0 +1,24 @@ +import Foundation +import Postbox +import TelegramCore +import Display + +func chatInfoController(account: Account, peer: Peer) -> ViewController? { + if let user = peer as? TelegramUser { + return UserInfoController(account: account, peerId: peer.id) + } else if let channel = peer as? TelegramChannel { + switch channel.info { + case .broadcast: + return ChannelBroadcastInfoController(account: account, peerId: peer.id) + case .group: + break + } + } else { + return PeerMediaCollectionController(account: account, peerId: peer.id) + } + return nil +} + +func peerSharedMediaController(account: Account, peerId: PeerId) -> ViewController? { + return PeerMediaCollectionController(account: account, peerId: peerId) +} diff --git a/TelegramUI/ChatListController.swift b/TelegramUI/ChatListController.swift index 567d13dc42..364e57dd1d 100644 --- a/TelegramUI/ChatListController.swift +++ b/TelegramUI/ChatListController.swift @@ -28,7 +28,7 @@ func ==(lhs: ChatListMessageViewPosition, rhs: ChatListMessageViewPosition) -> B } } -private enum ChatListControllerEntryId: Hashable { +private enum ChatListControllerEntryId: Hashable, CustomStringConvertible { case Search case PeerId(Int64) @@ -40,6 +40,15 @@ private enum ChatListControllerEntryId: Hashable { return peerId.hashValue } } + + var description: String { + switch self { + case .Search: + return "search" + case let .PeerId(value): + return "peerId(\(value))" + } + } } private func <(lhs: ChatListControllerEntryId, rhs: ChatListControllerEntryId) -> Bool { @@ -67,7 +76,7 @@ private func ==(lhs: ChatListControllerEntryId, rhs: ChatListControllerEntryId) private enum ChatListControllerEntry: Comparable, Identifiable { case SearchEntry - case MessageEntry(Message, Int) + case MessageEntry(Message, CombinedPeerReadState?, PeerNotificationSettings?) case HoleEntry(ChatListHole) case Nothing(MessageIndex) @@ -75,7 +84,7 @@ private enum ChatListControllerEntry: Comparable, Identifiable { switch self { case .SearchEntry: return MessageIndex.absoluteUpperBound() - case let .MessageEntry(message, _): + case let .MessageEntry(message, _, _): return MessageIndex(message) case let .HoleEntry(hole): return hole.index @@ -107,10 +116,20 @@ private func ==(lhs: ChatListControllerEntry, rhs: ChatListControllerEntry) -> B default: return false } - case let .MessageEntry(lhsMessage, lhsUnreadCount): + case let .MessageEntry(lhsMessage, lhsUnreadCount, lhsNotificationSettings): switch rhs { - case let .MessageEntry(rhsMessage, rhsUnreadCount): - return lhsMessage.id == rhsMessage.id && lhsMessage.flags == rhsMessage.flags && lhsUnreadCount == rhsUnreadCount + case let .MessageEntry(rhsMessage, rhsUnreadCount, rhsNotificationSettings): + if lhsMessage.id != rhsMessage.id || lhsMessage.flags != rhsMessage.flags || lhsUnreadCount != rhsUnreadCount { + return false + } + if let lhsNotificationSettings = lhsNotificationSettings, let rhsNotificationSettings = rhsNotificationSettings { + if !lhsNotificationSettings.isEqual(to: rhsNotificationSettings) { + return false + } + } else if (lhsNotificationSettings != nil) != (rhsNotificationSettings != nil) { + return false + } + return true default: break } @@ -302,8 +321,8 @@ public class ChatListController: ViewController { var result: [ChatListControllerEntry] = [] for entry in view.entries { switch entry { - case let .MessageEntry(message, unreadCount): - result.append(.MessageEntry(message, unreadCount)) + case let .MessageEntry(message, combinedReadState, notificationSettings): + result.append(.MessageEntry(message, combinedReadState, notificationSettings)) case let .HoleEntry(hole): result.append(.HoleEntry(hole)) case let .Nothing(index): @@ -324,9 +343,9 @@ public class ChatListController: ViewController { let viewEntries = strongSelf.chatListControllerEntries(view) strongSelf.messageViewQueue.async { - //let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: currentEntries, rightList: viewEntries) - let (deleteIndices, indicesAndItems) = mergeListsStable(leftList: currentEntries, rightList: viewEntries) - let updateIndices: [(Int, ChatListControllerEntry)] = [] + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: currentEntries, rightList: viewEntries) + //let (deleteIndices, indicesAndItems) = mergeListsStable(leftList: currentEntries, rightList: viewEntries) + //let updateIndices: [(Int, ChatListControllerEntry)] = [] Queue.mainQueue().async { var adjustedDeleteIndices: [ListViewDeleteItem] = [] @@ -368,8 +387,8 @@ public class ChatListController: ViewController { adjustedIndicesAndItems.append(ListViewInsertItem(index: updatedCount - 1 - index, previousIndex: adjustedPreviousIndex, item: ChatListSearchItem(placeholder: "Search for messages or users", activate: { [weak self] in self?.activateSearch() }), directionHint: directionHint)) - case let .MessageEntry(message, unreadCount): - adjustedIndicesAndItems.append(ListViewInsertItem(index: adjustedIndex, previousIndex: adjustedPreviousIndex, item: ChatListItem(account: strongSelf.account, message: message, unreadCount: unreadCount, action: { [weak self] message in + case let .MessageEntry(message, combinedReadState, notificationSettings): + adjustedIndicesAndItems.append(ListViewInsertItem(index: adjustedIndex, previousIndex: adjustedPreviousIndex, item: ChatListItem(account: strongSelf.account, message: message, combinedReadState: combinedReadState, notificationSettings: notificationSettings, action: { [weak self] message in if let strongSelf = self { strongSelf.entrySelected(entry) strongSelf.chatListDisplayNode.listView.clearHighlightAnimated(true) @@ -383,27 +402,28 @@ public class ChatListController: ViewController { } var adjustedUpdateItems: [ListViewUpdateItem] = [] - for (index, entry) in updateIndices { + for (index, entry, previousIndex) in updateIndices { let adjustedIndex = updatedCount - 1 - index + let adjustedPreviousIndex = previousCount - 1 - previousIndex let directionHint: ListViewItemOperationDirectionHint? = nil switch entry { case .SearchEntry: - adjustedUpdateItems.append(ListViewUpdateItem(index: updatedCount - 1 - index, item: ChatListSearchItem(placeholder: "Search for messages or users", activate: { [weak self] in + adjustedUpdateItems.append(ListViewUpdateItem(index: adjustedIndex, previousIndex: adjustedPreviousIndex, item: ChatListSearchItem(placeholder: "Search for messages or users", activate: { [weak self] in self?.activateSearch() }), directionHint: directionHint)) - case let .MessageEntry(message, unreadCount): - adjustedUpdateItems.append(ListViewUpdateItem(index: adjustedIndex, item: ChatListItem(account: strongSelf.account, message: message, unreadCount: unreadCount, action: { [weak self] message in + case let .MessageEntry(message, combinedReadState, notificationSettings): + adjustedUpdateItems.append(ListViewUpdateItem(index: adjustedIndex, previousIndex: adjustedPreviousIndex, item: ChatListItem(account: strongSelf.account, message: message, combinedReadState: combinedReadState, notificationSettings: notificationSettings, action: { [weak self] message in if let strongSelf = self { strongSelf.entrySelected(entry) strongSelf.chatListDisplayNode.listView.clearHighlightAnimated(true) } }), directionHint: directionHint)) case .HoleEntry: - adjustedUpdateItems.append(ListViewUpdateItem(index: updatedCount - 1 - index, item: ChatListHoleItem(), directionHint: directionHint)) + adjustedUpdateItems.append(ListViewUpdateItem(index: adjustedIndex, previousIndex: adjustedPreviousIndex, item: ChatListHoleItem(), directionHint: directionHint)) case .Nothing: - adjustedUpdateItems.append(ListViewUpdateItem(index: updatedCount - 1 - index, item: ChatListEmptyItem(), directionHint: directionHint)) + adjustedUpdateItems.append(ListViewUpdateItem(index: adjustedIndex, previousIndex: adjustedPreviousIndex, item: ChatListEmptyItem(), directionHint: directionHint)) } } @@ -464,7 +484,8 @@ public class ChatListController: ViewController { } private func entrySelected(_ entry: ChatListControllerEntry) { - if case let .MessageEntry(message, _) = entry { + if case let .MessageEntry(message, _, _) = entry { + //(self.navigationController as? NavigationController)?.pushViewController(PeerMediaCollectionController(account: self.account, peerId: message.id.peerId)) (self.navigationController as? NavigationController)?.pushViewController(ChatController(account: self.account, peerId: message.id.peerId)) } } diff --git a/TelegramUI/ChatListItem.swift b/TelegramUI/ChatListItem.swift index 91b408417f..3f5021c131 100644 --- a/TelegramUI/ChatListItem.swift +++ b/TelegramUI/ChatListItem.swift @@ -9,22 +9,24 @@ import TelegramCore class ChatListItem: ListViewItem { let account: Account let message: Message - let unreadCount: Int + let combinedReadState: CombinedPeerReadState? + let notificationSettings: PeerNotificationSettings? let action: (Message) -> Void let selectable: Bool = true - init(account: Account, message: Message, unreadCount: Int, action: @escaping (Message) -> Void) { + init(account: Account, message: Message, combinedReadState: CombinedPeerReadState?, notificationSettings: PeerNotificationSettings?, action: @escaping (Message) -> Void) { self.account = account self.message = message - self.unreadCount = unreadCount + self.combinedReadState = combinedReadState + self.notificationSettings = notificationSettings self.action = action } func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> Void) -> Void) { async { let node = ChatListItemNode() - node.setupItem(account: self.account, message: self.message, unreadCount: self.unreadCount) + node.setupItem(account: self.account, message: self.message, combinedReadState: self.combinedReadState, notificationSettings: self.notificationSettings) node.relativePosition = (first: previousItem == nil, last: nextItem == nil) node.insets = ChatListItemNode.insets(first: node.relativePosition.first, last: node.relativePosition.last) node.layoutForWidth(width, item: self, previousItem: previousItem, nextItem: nextItem) @@ -35,7 +37,7 @@ class ChatListItem: ListViewItem { func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { if let node = node as? ChatListItemNode { Queue.mainQueue().async { - node.setupItem(account: self.account, message: self.message, unreadCount: self.unreadCount) + node.setupItem(account: self.account, message: self.message, combinedReadState: self.combinedReadState, notificationSettings: self.notificationSettings) let layout = node.asyncLayout() async { let first = previousItem == nil @@ -53,7 +55,7 @@ class ChatListItem: ListViewItem { } } - func selected() { + func selected(listView: ListView) { self.action(self.message) } } @@ -87,24 +89,31 @@ private func generateStatusCheckImage(single: Bool) -> UIImage? { }) } -private func generateBadgeBackgroundImage() -> UIImage? { +private func generateBadgeBackgroundImage(active: Bool) -> UIImage? { return generateImage(CGSize(width: 20.0, height: 20.0), contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) - context.setFillColor(UIColor(0x1195f2).cgColor) + if active { + context.setFillColor(UIColor(0x1195f2).cgColor) + } else { + context.setFillColor(UIColor(0xbbbbbb).cgColor) + } context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) })?.stretchableImage(withLeftCapWidth: 10, topCapHeight: 10) } private let statusSingleCheckImage = generateStatusCheckImage(single: true) private let statusDoubleCheckImage = generateStatusCheckImage(single: false) -private let badgeBackgroundImage = generateBadgeBackgroundImage() +private let activeBadgeBackgroundImage = generateBadgeBackgroundImage(active: true) +private let inactiveBadgeBackgroundImage = generateBadgeBackgroundImage(active: false) +private let peerMutedIcon = UIImage(bundleImageName: "Chat List/PeerMutedIcon")?.precomposed() private let separatorHeight = 1.0 / UIScreen.main.scale class ChatListItemNode: ListViewItemNode { var account: Account? var message: Message? - var unreadCount: Int = 0 + var combinedReadState: CombinedPeerReadState? + var notificationSettings: PeerNotificationSettings? private let highlightedBackgroundNode: ASDisplayNode @@ -117,6 +126,7 @@ class ChatListItemNode: ListViewItemNode { let separatorNode: ASDisplayNode let badgeBackgroundNode: ASImageNode let badgeTextNode: TextNode + let mutedIconNode: ASImageNode var relativePosition: (first: Bool, last: Bool) = (false, false) @@ -163,6 +173,11 @@ class ChatListItemNode: ListViewItemNode { self.badgeTextNode.isLayerBacked = true self.badgeTextNode.displaysAsynchronously = true + self.mutedIconNode = ASImageNode() + self.mutedIconNode.isLayerBacked = true + self.mutedIconNode.displaysAsynchronously = false + self.mutedIconNode.displayWithoutProcessing = true + self.separatorNode = ASDisplayNode() self.separatorNode.backgroundColor = UIColor(0xc8c7cc) self.separatorNode.isLayerBacked = true @@ -179,12 +194,14 @@ class ChatListItemNode: ListViewItemNode { self.contentNode.addSubnode(self.statusNode) self.contentNode.addSubnode(self.badgeBackgroundNode) self.contentNode.addSubnode(self.badgeTextNode) + self.contentNode.addSubnode(self.mutedIconNode) } - func setupItem(account: Account, message: Message, unreadCount: Int) { + func setupItem(account: Account, message: Message, combinedReadState: CombinedPeerReadState?, notificationSettings: PeerNotificationSettings?) { self.account = account self.message = message - self.unreadCount = unreadCount + self.combinedReadState = combinedReadState + self.notificationSettings = notificationSettings let peer = message.peers[message.id.peerId] if let peer = peer { @@ -252,7 +269,8 @@ class ChatListItemNode: ListViewItemNode { let badgeTextLayout = TextNode.asyncLayout(self.badgeTextNode) let message = self.message - let unreadCount = self.unreadCount + let combinedReadState = self.combinedReadState + let notificationSettings = self.notificationSettings return { account, width, first, last in var textAttributedString: NSAttributedString? @@ -262,6 +280,7 @@ class ChatListItemNode: ListViewItemNode { var statusImage: UIImage? var currentBadgeBackgroundImage: UIImage? + var currentMutedIconImage: UIImage? if let message = message { let peer = message.peers[message.id.peerId] @@ -316,18 +335,44 @@ class ChatListItemNode: ListViewItemNode { if message.author?.id == account?.peerId { if !message.flags.contains(.Unsent) && !message.flags.contains(.Failed) { - statusImage = statusDoubleCheckImage + if let combinedReadState = combinedReadState, combinedReadState.isOutgoingMessageIdRead(message.id) { + statusImage = statusDoubleCheckImage + } else { + statusImage = statusSingleCheckImage + } } } - if unreadCount != 0 { - currentBadgeBackgroundImage = badgeBackgroundImage - badgeAttributedString = NSAttributedString(string: "\(unreadCount)", font: badgeFont, textColor: UIColor.white) + if let combinedReadState = combinedReadState { + let unreadCount = combinedReadState.count + if unreadCount != 0 { + if let notificationSettings = notificationSettings as? TelegramPeerNotificationSettings { + if case .unmuted = notificationSettings.muteState { + currentBadgeBackgroundImage = activeBadgeBackgroundImage + } else { + currentBadgeBackgroundImage = inactiveBadgeBackgroundImage + } + } else { + currentBadgeBackgroundImage = activeBadgeBackgroundImage + } + badgeAttributedString = NSAttributedString(string: "\(unreadCount)", font: badgeFont, textColor: UIColor.white) + } + } + + if let notificationSettings = notificationSettings as? TelegramPeerNotificationSettings { + if case .muted = notificationSettings.muteState { + currentMutedIconImage = peerMutedIcon + } } } let statusWidth = statusImage?.size.width ?? 0.0 + var muteWidth: CGFloat = 0.0 + if let currentMutedIconImage = currentMutedIconImage { + muteWidth = currentMutedIconImage.size.width + 4.0 + } + let contentRect = CGRect(origin: CGPoint(x: 2.0, y: 12.0), size: CGSize(width: width - 78.0 - 10.0 - 1.0, height: 68.0 - 12.0 - 9.0)) let (dateLayout, dateApply) = dateLayout(dateAttributedString, nil, 1, .end, CGSize(width: contentRect.width, height: CGFloat.greatestFiniteMagnitude), nil) @@ -343,7 +388,7 @@ class ChatListItemNode: ListViewItemNode { let (textLayout, textApply) = textLayout(textAttributedString, nil, 1, .end, CGSize(width: contentRect.width - badgeSize, height: CGFloat.greatestFiniteMagnitude), nil) - let titleRect = CGRect(origin: contentRect.origin, size: CGSize(width: contentRect.width - dateLayout.size.width - 10.0 - statusWidth, height: contentRect.height)) + let titleRect = CGRect(origin: contentRect.origin, size: CGSize(width: contentRect.width - dateLayout.size.width - 10.0 - statusWidth - muteWidth, height: contentRect.height)) let (titleLayout, titleApply) = titleLayout(titleAttributedString, nil, 1, .end, CGSize(width: titleRect.width, height: CGFloat.greatestFiniteMagnitude), nil) let insets = ChatListItemNode.insets(first: first, last: last) @@ -388,6 +433,22 @@ class ChatListItemNode: ListViewItemNode { strongSelf.badgeBackgroundNode.isHidden = true } + var updateContentNode = false + if let currentMutedIconImage = currentMutedIconImage { + strongSelf.mutedIconNode.image = currentMutedIconImage + if strongSelf.mutedIconNode.isHidden { + updateContentNode = true + } + strongSelf.mutedIconNode.isHidden = false + strongSelf.mutedIconNode.frame = CGRect(origin: CGPoint(x: contentRect.origin.x + titleLayout.size.width + 3.0, y: contentRect.origin.y + 6.0), size: currentMutedIconImage.size) + } else { + if !strongSelf.mutedIconNode.isHidden { + updateContentNode = true + } + strongSelf.mutedIconNode.image = nil + strongSelf.mutedIconNode.isHidden = true + } + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: contentRect.origin.x, y: contentRect.origin.y), size: titleLayout.size) strongSelf.textNode.frame = CGRect(origin: CGPoint(x: contentRect.origin.x, y: contentRect.maxY - textLayout.size.height - 1.0), size: textLayout.size) @@ -397,6 +458,10 @@ class ChatListItemNode: ListViewItemNode { strongSelf.contentSize = layout.contentSize strongSelf.insets = layout.insets strongSelf.updateBackgroundAndSeparatorsLayout() + + if updateContentNode { + strongSelf.contentNode.setNeedsDisplay() + } } }) } diff --git a/TelegramUI/ChatListSearchContainerNode.swift b/TelegramUI/ChatListSearchContainerNode.swift index 0ddd5db712..f22f32de02 100644 --- a/TelegramUI/ChatListSearchContainerNode.swift +++ b/TelegramUI/ChatListSearchContainerNode.swift @@ -59,7 +59,7 @@ final class ChatListSearchContainerNode: SearchDisplayControllerContentNode { for item in items { switch item { case let .message(message): - listItems.append(ChatListItem(account: account, message: message, unreadCount: 0, action: { [weak strongSelf] _ in + listItems.append(ChatListItem(account: account, message: message, combinedReadState: nil, notificationSettings: nil, action: { [weak strongSelf] _ in if let strongSelf = strongSelf, let peer = message.peers[message.id.peerId] { strongSelf.listNode.clearHighlightAnimated(true) strongSelf.openMessage(peer, message.id) diff --git a/TelegramUI/ChatMediaActionSheetRollItem.swift b/TelegramUI/ChatMediaActionSheetRollItem.swift index 76c3817b47..855dca4c11 100644 --- a/TelegramUI/ChatMediaActionSheetRollItem.swift +++ b/TelegramUI/ChatMediaActionSheetRollItem.swift @@ -88,7 +88,8 @@ private final class ChatMediaActionSheetRollItemNode: ActionSheetItemNode, PHPho self.listView.bounds = CGRect(x: 0.0, y: 0.0, width: 84.0, height: bounds.size.width) self.listView.position = CGPoint(x: bounds.size.width / 2.0, y: 84.0 / 2.0 + 8.0) - self.listView.updateSizeAndInsets(size: CGSize(width: 84.0, height: bounds.size.width), insets: UIEdgeInsets(top: 4.0, left: 0.0, bottom: 4.0, right: 0.0), duration: 0.0, options: UIViewAnimationOptions(rawValue: UInt(0))) + + let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: CGSize(width: 84.0, height: bounds.size.width), insets: UIEdgeInsets(top: 4.0, left: 0.0, bottom: 4.0, right: 0.0), duration: 0.0, curve: .Default) let labelSize = self.label.bounds.size self.label.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((bounds.size.width - labelSize.width) / 2.0), y: 84.0 + 16.0 + floorToScreenPixels((bounds.height - 84.0 - 16.0 - labelSize.height) / 2.0)), size: labelSize) diff --git a/TelegramUI/ChatMessageActionItemNode.swift b/TelegramUI/ChatMessageActionItemNode.swift index e1dbbfcb39..f14c117828 100644 --- a/TelegramUI/ChatMessageActionItemNode.swift +++ b/TelegramUI/ChatMessageActionItemNode.swift @@ -33,7 +33,7 @@ class ChatMessageActionItemNode: ChatMessageItemView { super.init(layerBacked: false) - self.backgroundNode.image = backgroundImage(color: UIColor.blue) + self.backgroundNode.image = backgroundImage(color: UIColor(0x1195f2)) self.addSubnode(self.backgroundNode) self.addSubnode(self.labelNode) } diff --git a/TelegramUI/ChatMessageBubbleContentNode.swift b/TelegramUI/ChatMessageBubbleContentNode.swift index 79fbc3a983..171bd5c333 100644 --- a/TelegramUI/ChatMessageBubbleContentNode.swift +++ b/TelegramUI/ChatMessageBubbleContentNode.swift @@ -41,7 +41,7 @@ class ChatMessageBubbleContentNode: ASDisplayNode { super.init() } - func asyncLayoutContent() -> (_ item: ChatMessageItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ position: ChatMessageBubbleContentPosition, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> Void))) { + func asyncLayoutContent() -> (_ item: ChatMessageItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ position: ChatMessageBubbleContentPosition, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))) { preconditionFailure() } diff --git a/TelegramUI/ChatMessageBubbleItemNode.swift b/TelegramUI/ChatMessageBubbleItemNode.swift index 4873a08ff0..00b008e556 100644 --- a/TelegramUI/ChatMessageBubbleItemNode.swift +++ b/TelegramUI/ChatMessageBubbleItemNode.swift @@ -220,7 +220,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { } override func asyncLayout() -> (_ item: ChatMessageItem, _ width: CGFloat, _ mergedTop: Bool, _ mergedBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { - var currentContentClassesPropertiesAndLayouts: [(AnyClass, ChatMessageBubbleContentProperties, (_ item: ChatMessageItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ position: ChatMessageBubbleContentPosition, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> Void))))] = [] + var currentContentClassesPropertiesAndLayouts: [(AnyClass, ChatMessageBubbleContentProperties, (_ item: ChatMessageItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ position: ChatMessageBubbleContentPosition, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))))] = [] for contentNode in self.contentNodes { currentContentClassesPropertiesAndLayouts.append((type(of: contentNode) as AnyClass, contentNode.properties, contentNode.asyncLayoutContent())) } @@ -233,7 +233,6 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { return { item, width, mergedTop, mergedBottom in let message = item.message - let incoming = item.message.effectivelyIncoming let displayAuthorInfo = !mergedTop && incoming && item.peerId.isGroupOrChannel && item.message.author != nil @@ -243,7 +242,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { let tmpWidth = width * layoutConstants.bubble.maximumWidthFillFactor let maximumContentWidth = floor(tmpWidth - layoutConstants.bubble.edgeInset - layoutConstants.bubble.edgeInset - layoutConstants.bubble.contentInsets.left - layoutConstants.bubble.contentInsets.right - avatarInset) - var contentPropertiesAndPrepareLayouts: [(ChatMessageBubbleContentProperties, (_ item: ChatMessageItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ position: ChatMessageBubbleContentPosition, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> Void))))] = [] + var contentPropertiesAndPrepareLayouts: [(ChatMessageBubbleContentProperties, (_ item: ChatMessageItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ position: ChatMessageBubbleContentPosition, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))))] = [] var addedContentNodes: [ChatMessageBubbleContentNode]? let contentNodeClasses = contentNodeClassesForItem(item) @@ -286,7 +285,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { } } - var contentPropertiesAndLayouts: [(ChatMessageBubbleContentProperties, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> Void)))] = [] + var contentPropertiesAndLayouts: [(ChatMessageBubbleContentProperties, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void)))] = [] let topNodeMergeStatus: ChatMessageBubbleMergeStatus = mergedTop ? (incoming ? .Left : .Right) : .None(incoming ? .Incoming : .Outgoing) let bottomNodeMergeStatus: ChatMessageBubbleMergeStatus = mergedBottom ? (incoming ? .Left : .Right) : .None(incoming ? .Incoming : .Outgoing) @@ -420,7 +419,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { } } - var contentNodePropertiesAndFinalize: [(ChatMessageBubbleContentProperties, (CGFloat) -> (CGSize, () -> Void))] = [] + var contentNodePropertiesAndFinalize: [(ChatMessageBubbleContentProperties, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))] = [] var maxContentWidth: CGFloat = headerSize.width for (contentNodeProperties, contentNodeLayout) in contentPropertiesAndLayouts { @@ -432,7 +431,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { var contentSize = CGSize(width: maxContentWidth, height: 0.0) index = 0 - var contentNodeSizesPropertiesAndApply: [(CGSize, ChatMessageBubbleContentProperties, () -> Void)] = [] + var contentNodeSizesPropertiesAndApply: [(CGSize, ChatMessageBubbleContentProperties, (ListViewItemUpdateAnimation) -> Void)] = [] for (properties, finalize) in contentNodePropertiesAndFinalize { let (size, apply) = finalize(maxContentWidth) contentNodeSizesPropertiesAndApply.append((size, properties, apply)) @@ -521,7 +520,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { var contentNodeOrigin = contentOrigin var contentNodeIndex = 0 for (size, properties, apply) in contentNodeSizesPropertiesAndApply { - apply() + apply(animation) if contentNodeIndex == 0 && headerSize.height > CGFloat(FLT_EPSILON) { contentNodeOrigin.y += properties.headerSpacing } diff --git a/TelegramUI/ChatMessageDateAndStatusNode.swift b/TelegramUI/ChatMessageDateAndStatusNode.swift index fb01507170..6f2cf2858e 100644 --- a/TelegramUI/ChatMessageDateAndStatusNode.swift +++ b/TelegramUI/ChatMessageDateAndStatusNode.swift @@ -98,7 +98,7 @@ class ChatMessageDateAndStatusNode: ASTransformLayerNode { self.addSubnode(self.dateNode) } - func asyncLayout() -> (_ dateText: String, _ type: ChatMessageDateAndStatusType, _ constrainedSize: CGSize) -> (CGSize, () -> Void) { + func asyncLayout() -> (_ dateText: String, _ type: ChatMessageDateAndStatusType, _ constrainedSize: CGSize) -> (CGSize, (Bool) -> Void) { let dateLayout = TextNode.asyncLayout(self.dateNode) var checkReadNode = self.checkReadNode @@ -190,7 +190,7 @@ class ChatMessageDateAndStatusNode: ASTransformLayerNode { let checkSize = checkFullImage!.size - checkSentFrame = CGRect(origin: CGPoint(x: leftInset + date.size.width + 5.0 + statusWidth - checkSize.width, y: 3.0), size: checkSize) + checkSentFrame = CGRect(origin: CGPoint(x: leftInset + date.size.width + 5.0 + statusWidth - checkSize.width - (read ? 0.0 : 2.5), y: 3.0), size: checkSize) if read { checkReadFrame = CGRect(origin: CGPoint(x: checkSentFrame!.origin.x - 6.0, y: checkSentFrame!.origin.y), size: checkSize) } @@ -211,7 +211,7 @@ class ChatMessageDateAndStatusNode: ASTransformLayerNode { clockMinNode = nil } - return (CGSize(width: leftInset + date.size.width + statusWidth, height: date.size.height), { [weak self] in + return (CGSize(width: leftInset + date.size.width + statusWidth, height: date.size.height), { [weak self] animated in if let strongSelf = self { let _ = dateApply() @@ -246,31 +246,52 @@ class ChatMessageDateAndStatusNode: ASTransformLayerNode { } if let checkSentNode = checkSentNode, let checkReadNode = checkReadNode { + var animateSentNode = false if strongSelf.checkSentNode == nil { strongSelf.checkSentNode = checkSentNode strongSelf.addSubnode(checkSentNode) + animateSentNode = animated + } + if checkReadFrame != nil { + checkSentNode.image = loadedCheckPartialImage + } else { + checkSentNode.image = loadedCheckFullImage } - checkSentNode.image = loadedCheckPartialImage if let checkSentFrame = checkSentFrame { + if checkSentNode.isHidden { + animateSentNode = animated + } checkSentNode.isHidden = false checkSentNode.frame = checkSentFrame } else { checkSentNode.isHidden = true } + var animateReadNode = false if strongSelf.checkReadNode == nil { + animateReadNode = animated strongSelf.checkReadNode = checkReadNode strongSelf.addSubnode(checkReadNode) } checkReadNode.image = loadedCheckFullImage - + if let checkReadFrame = checkReadFrame { + if checkReadNode.isHidden { + animateReadNode = animated + } checkReadNode.isHidden = false checkReadNode.frame = checkReadFrame } else { checkReadNode.isHidden = true } + + if animateSentNode { + strongSelf.checkSentNode?.layer.animateScale(from: 1.3, to: 1.0, duration: 0.1) + } + if animateReadNode { + strongSelf.checkReadNode?.layer.animateScale(from: 1.3, to: 1.0, duration: 0.1) + } } else if let checkSentNode = strongSelf.checkSentNode, let checkReadNode = strongSelf.checkReadNode { checkSentNode.removeFromSupernode() checkReadNode.removeFromSupernode() diff --git a/TelegramUI/ChatMessageFileBubbleContentNode.swift b/TelegramUI/ChatMessageFileBubbleContentNode.swift index 43be54acba..f1d44447a5 100644 --- a/TelegramUI/ChatMessageFileBubbleContentNode.swift +++ b/TelegramUI/ChatMessageFileBubbleContentNode.swift @@ -30,7 +30,7 @@ class ChatMessageFileBubbleContentNode: ChatMessageBubbleContentNode { fatalError("init(coder:) has not been implemented") } - override func asyncLayoutContent() -> (_ item: ChatMessageItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ position: ChatMessageBubbleContentPosition, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> Void))) { + override func asyncLayoutContent() -> (_ item: ChatMessageItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ position: ChatMessageBubbleContentPosition, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))) { let interactiveFileLayout = self.interactiveFileNode.asyncLayout() return { item, layoutConstants, position, constrainedSize in @@ -49,7 +49,7 @@ class ChatMessageFileBubbleContentNode: ChatMessageBubbleContentNode { return (refinedWidth + layoutConstants.file.bubbleInsets.left + layoutConstants.file.bubbleInsets.right, { boundingWidth in let (fileSize, fileApply) = finishLayout(boundingWidth - layoutConstants.file.bubbleInsets.left - layoutConstants.file.bubbleInsets.right) - return (CGSize(width: fileSize.width + layoutConstants.file.bubbleInsets.left + layoutConstants.file.bubbleInsets.right, height: fileSize.height + layoutConstants.file.bubbleInsets.top + layoutConstants.file.bubbleInsets.bottom), { [weak self] in + return (CGSize(width: fileSize.width + layoutConstants.file.bubbleInsets.left + layoutConstants.file.bubbleInsets.right, height: fileSize.height + layoutConstants.file.bubbleInsets.top + layoutConstants.file.bubbleInsets.bottom), { [weak self] _ in if let strongSelf = self { strongSelf.item = item diff --git a/TelegramUI/ChatMessageInteractiveMediaNode.swift b/TelegramUI/ChatMessageInteractiveMediaNode.swift index 9e3711184d..a1d12beb75 100644 --- a/TelegramUI/ChatMessageInteractiveMediaNode.swift +++ b/TelegramUI/ChatMessageInteractiveMediaNode.swift @@ -63,7 +63,7 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { @objc func imageTap(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { - if let file = media as? TelegramMediaFile, (file.isVideo || file.mimeType.hasPrefix("video/")) { + if let file = media as? TelegramMediaFile, (file.isVideo || file.isAnimated || file.mimeType.hasPrefix("video/")) { self.activateLocalContent() } else { if let fetchStatus = self.fetchStatus, case .Local = fetchStatus { diff --git a/TelegramUI/ChatMessageItem.swift b/TelegramUI/ChatMessageItem.swift index cbc7302cb3..23296f2844 100644 --- a/TelegramUI/ChatMessageItem.swift +++ b/TelegramUI/ChatMessageItem.swift @@ -41,14 +41,16 @@ public class ChatMessageItem: ListViewItem, CustomStringConvertible { let peerId: PeerId let controllerInteraction: ChatControllerInteraction let message: Message + let read: Bool public let accessoryItem: ListViewAccessoryItem? - public init(account: Account, peerId: PeerId, controllerInteraction: ChatControllerInteraction, message: Message) { + public init(account: Account, peerId: PeerId, controllerInteraction: ChatControllerInteraction, message: Message, read: Bool) { self.account = account self.peerId = peerId self.controllerInteraction = controllerInteraction self.message = message + self.read = read var accessoryItem: ListViewAccessoryItem? let incoming = message.effectivelyIncoming diff --git a/TelegramUI/ChatMessageMediaBubbleContentNode.swift b/TelegramUI/ChatMessageMediaBubbleContentNode.swift index 0d83060aad..f0562d975f 100644 --- a/TelegramUI/ChatMessageMediaBubbleContentNode.swift +++ b/TelegramUI/ChatMessageMediaBubbleContentNode.swift @@ -35,7 +35,7 @@ class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { fatalError("init(coder:) has not been implemented") } - override func asyncLayoutContent() -> (_ item: ChatMessageItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ position: ChatMessageBubbleContentPosition, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> Void))) { + override func asyncLayoutContent() -> (_ item: ChatMessageItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ position: ChatMessageBubbleContentPosition, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))) { let interactiveImageLayout = self.interactiveImageNode.asyncLayout() return { item, layoutConstants, position, constrainedSize in @@ -58,7 +58,7 @@ class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { return (refinedWidth + layoutConstants.image.bubbleInsets.left + layoutConstants.image.bubbleInsets.right, { boundingWidth in let (imageSize, imageApply) = finishLayout(boundingWidth - layoutConstants.image.bubbleInsets.left - layoutConstants.image.bubbleInsets.right) - return (CGSize(width: imageSize.width + layoutConstants.image.bubbleInsets.left + layoutConstants.image.bubbleInsets.right, height: imageSize.height + layoutConstants.image.bubbleInsets.top + layoutConstants.image.bubbleInsets.bottom), { [weak self] in + return (CGSize(width: imageSize.width + layoutConstants.image.bubbleInsets.left + layoutConstants.image.bubbleInsets.right, height: imageSize.height + layoutConstants.image.bubbleInsets.top + layoutConstants.image.bubbleInsets.bottom), { [weak self] _ in if let strongSelf = self { strongSelf.item = item strongSelf.media = selectedMedia diff --git a/TelegramUI/ChatMessageTextBubbleContentNode.swift b/TelegramUI/ChatMessageTextBubbleContentNode.swift index 7db880bd3c..699c44c43c 100644 --- a/TelegramUI/ChatMessageTextBubbleContentNode.swift +++ b/TelegramUI/ChatMessageTextBubbleContentNode.swift @@ -27,7 +27,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { fatalError("init(coder:) has not been implemented") } - override func asyncLayoutContent() -> (_ item: ChatMessageItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ position: ChatMessageBubbleContentPosition, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> Void))) { + override func asyncLayoutContent() -> (_ item: ChatMessageItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ position: ChatMessageBubbleContentPosition, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))) { let textLayout = TextNode.asyncLayout(self.textNode) let statusLayout = self.statusNode.asyncLayout() @@ -57,7 +57,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } else if message.flags.contains(.Unsent) { statusType = .BubbleOutgoing(.Sending) } else { - statusType = .BubbleOutgoing(.Sent(read: true)) + statusType = .BubbleOutgoing(.Sent(read: item.read)) } } } else { @@ -65,7 +65,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } var statusSize: CGSize? - var statusApply: (() -> Void)? + var statusApply: ((Bool) -> Void)? if let statusType = statusType { let (size, apply) = statusLayout(dateText, statusType, textConstrainedSize) @@ -136,13 +136,17 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { adjustedStatusFrame = CGRect(origin: CGPoint(x: boundingWidth - statusFrame.size.width - layoutConstants.text.bubbleInsets.right, y: statusFrame.origin.y), size: statusFrame.size) } - return (boundingSize, { [weak self] in + return (boundingSize, { [weak self] animation in if let strongSelf = self { let _ = textApply() if let statusApply = statusApply, let adjustedStatusFrame = adjustedStatusFrame { strongSelf.statusNode.frame = adjustedStatusFrame - statusApply() + var hasAnimation = true + if case .None = animation { + hasAnimation = false + } + statusApply(hasAnimation) if strongSelf.statusNode.supernode == nil { strongSelf.addSubnode(strongSelf.statusNode) } diff --git a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift index be9bbe9c16..00453adcdb 100644 --- a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift +++ b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift @@ -64,7 +64,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { fatalError("init(coder:) has not been implemented") } - override func asyncLayoutContent() -> (_ item: ChatMessageItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ position: ChatMessageBubbleContentPosition, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> Void))) { + override func asyncLayoutContent() -> (_ item: ChatMessageItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ position: ChatMessageBubbleContentPosition, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))) { let textAsyncLayout = TextNode.asyncLayout(self.textNode) let currentImage = self.image let imageLayout = self.inlineImageNode.asyncLayout() @@ -176,7 +176,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { let textConstrainedSize = CGSize(width: constrainedSize.width - insets.left - insets.right, height: constrainedSize.height - insets.top - insets.bottom) - var statusSizeAndApply: (CGSize, () -> Void)? + var statusSizeAndApply: (CGSize, (Bool) -> Void)? if refineContentImageLayout == nil && refineContentFileLayout == nil { statusSizeAndApply = statusLayout(dateText, statusType, textConstrainedSize) @@ -296,8 +296,13 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { adjustedStatusFrame = CGRect(origin: CGPoint(x: boundingWidth - statusFrame.size.width - insets.right, y: statusFrame.origin.y), size: statusFrame.size) } - return (adjustedBoundingSize, { [weak self] in + return (adjustedBoundingSize, { [weak self] animation in if let strongSelf = self { + var hasAnimation = true + if case .None = animation { + hasAnimation = false + } + strongSelf.lineNode.image = lineImage strongSelf.lineNode.frame = CGRect(origin: CGPoint(x: 9.0, y: 0.0), size: CGSize(width: 2.0, height: adjustedLineHeight - insets.top - insets.bottom - 2.0)) @@ -309,7 +314,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { if strongSelf.statusNode.supernode == nil { strongSelf.addSubnode(strongSelf.statusNode) } - statusApply() + statusApply(hasAnimation) } else if strongSelf.statusNode.supernode != nil { strongSelf.statusNode.removeFromSupernode() } diff --git a/TelegramUI/ChatTitleView.swift b/TelegramUI/ChatTitleView.swift new file mode 100644 index 0000000000..145fd8da5f --- /dev/null +++ b/TelegramUI/ChatTitleView.swift @@ -0,0 +1,86 @@ +import Foundation +import AsyncDisplayKit +import Display +import Postbox +import TelegramCore + +final class ChatTitleView: UIView { + private let titleNode: ASTextNode + private let infoNode: ASTextNode + + var peerView: PeerView? { + didSet { + if let peerView = self.peerView, let peer = peerView.peers[peerView.peerId] { + self.titleNode.attributedText = NSAttributedString(string: peer.displayTitle, font: Font.medium(17.0), textColor: UIColor.black) + + if let user = peer as? TelegramUser { + self.infoNode.attributedText = NSAttributedString(string: "last seen recently", font: Font.regular(13.0), textColor: UIColor(0x787878)) + } else if let group = peer as? TelegramGroup { + self.infoNode.attributedText = NSAttributedString(string: "\(group.participantCount) members", font: Font.regular(13.0), textColor: UIColor(0x787878)) + } else if let channel = peer as? TelegramChannel { + if let cachedChannelData = peerView.cachedData as? CachedChannelData, let memberCount = cachedChannelData.participantsSummary.memberCount { + self.infoNode.attributedText = NSAttributedString(string: "\(memberCount) members", font: Font.regular(13.0), textColor: UIColor(0x787878)) + } else { + switch channel.info { + case .group: + self.infoNode.attributedText = NSAttributedString(string: "group", font: Font.regular(13.0), textColor: UIColor(0x787878)) + case .broadcast: + self.infoNode.attributedText = NSAttributedString(string: "channel", font: Font.regular(13.0), textColor: UIColor(0x787878)) + } + } + } + + self.setNeedsLayout() + } + } + } + + override init(frame: CGRect) { + self.titleNode = ASTextNode() + self.titleNode.displaysAsynchronously = false + self.titleNode.maximumNumberOfLines = 1 + self.titleNode.truncationMode = .byTruncatingTail + self.titleNode.isOpaque = false + + self.infoNode = ASTextNode() + self.infoNode.displaysAsynchronously = false + self.infoNode.maximumNumberOfLines = 1 + self.infoNode.truncationMode = .byTruncatingTail + self.infoNode.isOpaque = false + + super.init(frame: frame) + + self.addSubnode(self.titleNode) + self.addSubnode(self.infoNode) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + let size = self.bounds.size + + if size.height > 40.0 { + let titleSize = self.titleNode.measure(size) + let infoSize = self.infoNode.measure(size) + let titleInfoSpacing: CGFloat = 0.0 + + let combinedHeight = titleSize.height + infoSize.height + titleInfoSpacing + + self.titleNode.frame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: floor((size.height - combinedHeight) / 2.0)), size: titleSize) + self.infoNode.frame = CGRect(origin: CGPoint(x: floor((size.width - infoSize.width) / 2.0), y: floor((size.height - combinedHeight) / 2.0) + titleSize.height + titleInfoSpacing), size: infoSize) + } else { + let titleSize = self.titleNode.measure(CGSize(width: floor(size.width / 2.0), height: size.height)) + let infoSize = self.infoNode.measure(CGSize(width: floor(size.width / 2.0), height: size.height)) + + let titleInfoSpacing: CGFloat = 8.0 + let combinedWidth = titleSize.width + infoSize.width + titleInfoSpacing + + self.titleNode.frame = CGRect(origin: CGPoint(x: floor((size.width - combinedWidth) / 2.0), y: floor((size.height - titleSize.height) / 2.0)), size: titleSize) + self.infoNode.frame = CGRect(origin: CGPoint(x: floor((size.width - combinedWidth) / 2.0 + titleSize.width + titleInfoSpacing), y: floor((size.height - infoSize.height) / 2.0)), size: infoSize) + } + } +} diff --git a/TelegramUI/ChatVideoGalleryItem.swift b/TelegramUI/ChatVideoGalleryItem.swift index faa4313b94..928c3c8af9 100644 --- a/TelegramUI/ChatVideoGalleryItem.swift +++ b/TelegramUI/ChatVideoGalleryItem.swift @@ -207,10 +207,6 @@ final class ChatVideoGalleryItemNode: ZoomableContentGalleryItemNode { self.videoNode.snapshotNode?.isHidden = true transformedFrame.origin = CGPoint() - /*self.videoNode.layer.animateBounds(from: self.videoNode.layer.bounds, to: transformedFrame, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in - boundsCompleted = true - intermediateCompletion() - })*/ let transform = CATransform3DScale(self.videoNode.layer.transform, transformedFrame.size.width / self.videoNode.layer.bounds.size.width, transformedFrame.size.height / self.videoNode.layer.bounds.size.height, 1.0) self.videoNode.layer.animate(from: NSValue(caTransform3D: self.videoNode.layer.transform), to: NSValue(caTransform3D: transform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in diff --git a/TelegramUI/ContactsPeerItem.swift b/TelegramUI/ContactsPeerItem.swift index 3bb7775ab3..e5b9f3f27a 100644 --- a/TelegramUI/ContactsPeerItem.swift +++ b/TelegramUI/ContactsPeerItem.swift @@ -112,7 +112,7 @@ class ContactsPeerItem: ListViewItem { } } - func selected() { + func selected(listView: ListView) { self.action(self.peer) } } diff --git a/TelegramUI/ContactsVCardItem.swift b/TelegramUI/ContactsVCardItem.swift index d514409f11..704ebfdbdd 100644 --- a/TelegramUI/ContactsVCardItem.swift +++ b/TelegramUI/ContactsVCardItem.swift @@ -54,7 +54,7 @@ class ContactsVCardItem: ListViewItem { } } - func selected() { + func selected(listView: ListView) { self.action(self.peer) } } diff --git a/TelegramUI/GalleryController.swift b/TelegramUI/GalleryController.swift index 1b645bfc35..e6d5410862 100644 --- a/TelegramUI/GalleryController.swift +++ b/TelegramUI/GalleryController.swift @@ -13,7 +13,9 @@ private func tagsForMessage(_ message: Message) -> MessageTags? { return .PhotoOrVideo case let file as TelegramMediaFile: if file.isVideo { - return .PhotoOrVideo + if !file.isAnimated { + return .PhotoOrVideo + } } else if file.isVoice { return .Voice } else if file.isSticker { @@ -47,7 +49,7 @@ private func mediaForMessage(message: Message) -> Media? { private func itemForEntry(account: Account, entry: MessageHistoryEntry) -> GalleryItem { switch entry { - case let .MessageEntry(message, location): + case let .MessageEntry(message, _, location): if let media = mediaForMessage(message: message) { if let _ = media as? TelegramMediaImage { return ChatImageGalleryItem(account: account, message: message, location: location) @@ -69,11 +71,37 @@ private func itemForEntry(account: Account, entry: MessageHistoryEntry) -> Galle return ChatHoleGalleryItem() } -class GalleryControllerPresentationArguments { - let transitionNode: (MessageId, Media) -> ASDisplayNode? +final class GalleryTransitionArguments { + let transitionNode: ASDisplayNode + let transitionContainerNode: ASDisplayNode + let transitionBackgroundNode: ASDisplayNode - init(transitionNode: @escaping (MessageId, Media) -> ASDisplayNode?) { + init(transitionNode: ASDisplayNode, transitionContainerNode: ASDisplayNode, transitionBackgroundNode: ASDisplayNode) { self.transitionNode = transitionNode + self.transitionContainerNode = transitionContainerNode + self.transitionBackgroundNode = transitionBackgroundNode + } +} + +final class GalleryControllerPresentationArguments { + let transitionArguments: (MessageId, Media) -> GalleryTransitionArguments? + + init(transitionArguments: @escaping (MessageId, Media) -> GalleryTransitionArguments?) { + self.transitionArguments = transitionArguments + } +} + +private enum GalleryMessageHistoryView { + case view(MessageHistoryView) + case single(MessageHistoryEntry) + + var entries: [MessageHistoryEntry] { + switch self { + case let .view(view): + return view.entries + case let .single(entry): + return [entry] + } } } @@ -123,16 +151,17 @@ class GalleryController: ViewController { let messageView = message |> filter({ $0 != nil }) - |> mapToSignal { message -> Signal in + |> mapToSignal { message -> Signal in if let tags = tagsForMessage(message!) { let view = account.postbox.aroundMessageHistoryViewForPeerId(messageId.peerId, index: MessageIndex(message!), count: 50, anchorIndex: MessageIndex(message!), fixedCombinedReadState: nil, tagMask: tags) return view - |> mapToSignal { (view, _) -> Signal in - return .single(view) + |> mapToSignal { (view, _) -> Signal in + let mapped = GalleryMessageHistoryView.view(view) + return .single(mapped) } } else { - return .single(nil) + return .single(GalleryMessageHistoryView.single(MessageHistoryEntry.MessageEntry(message!, false, nil))) } } |> take(1) @@ -144,7 +173,7 @@ class GalleryController: ViewController { strongSelf.entries = view.entries loop: for i in 0 ..< strongSelf.entries.count { switch strongSelf.entries[i] { - case let .MessageEntry(message, _) where message.id == messageId: + case let .MessageEntry(message, _, _) where message.id == messageId: strongSelf.centralEntryIndex = i break loop default: @@ -180,14 +209,16 @@ class GalleryController: ViewController { strongSelf.navigationBar.stripeColor = UIColor.clear strongSelf.navigationBar.foregroundColor = UIColor.white strongSelf.navigationBar.accentColor = UIColor.white - strongSelf.galleryNode.backgroundColor = UIColor.black + strongSelf.galleryNode.backgroundNode.backgroundColor = UIColor.black + strongSelf.galleryNode.isBackgroundExtendedOverNavigationBar = true case .light: strongSelf.statusBar.style = .Black strongSelf.navigationBar.backgroundColor = UIColor(red: 0.968626451, green: 0.968626451, blue: 0.968626451, alpha: 1.0) strongSelf.navigationBar.foregroundColor = UIColor.black strongSelf.navigationBar.accentColor = UIColor(0x1195f2) strongSelf.navigationBar.stripeColor = UIColor(red: 0.6953125, green: 0.6953125, blue: 0.6953125, alpha: 1.0) - strongSelf.galleryNode.backgroundColor = UIColor(0xbdbdc2) + strongSelf.galleryNode.backgroundNode.backgroundColor = UIColor(0xbdbdc2) + strongSelf.galleryNode.isBackgroundExtendedOverNavigationBar = false } } })) @@ -214,10 +245,10 @@ class GalleryController: ViewController { } if let centralItemNode = self.galleryNode.pager.centralItemNode(), let presentationArguments = self.presentationArguments as? GalleryControllerPresentationArguments { - if case let .MessageEntry(message, _) = self.entries[centralItemNode.index] { - if let media = mediaForMessage(message: message), let node = presentationArguments.transitionNode(message.id, media) { + if case let .MessageEntry(message, _, _) = self.entries[centralItemNode.index] { + if let media = mediaForMessage(message: message), let transitionArguments = presentationArguments.transitionArguments(message.id, media) { animatedOutNode = false - centralItemNode.animateOut(to: node, completion: { + centralItemNode.animateOut(to: transitionArguments.transitionNode, completion: { animatedOutNode = true completion() }) @@ -241,9 +272,9 @@ class GalleryController: ViewController { self.galleryNode.transitionNodeForCentralItem = { [weak self] in if let strongSelf = self { if let centralItemNode = strongSelf.galleryNode.pager.centralItemNode(), let presentationArguments = strongSelf.presentationArguments as? GalleryControllerPresentationArguments { - if case let .MessageEntry(message, _) = strongSelf.entries[centralItemNode.index] { - if let media = mediaForMessage(message: message), let node = presentationArguments.transitionNode(message.id, media) { - return node + if case let .MessageEntry(message, _, _) = strongSelf.entries[centralItemNode.index] { + if let media = mediaForMessage(message: message), let transitionArguments = presentationArguments.transitionArguments(message.id, media) { + return transitionArguments.transitionNode } } } @@ -261,7 +292,7 @@ class GalleryController: ViewController { if let strongSelf = self { var hiddenItem: (MessageId, Media)? if let index = index { - if case let .MessageEntry(message, _) = strongSelf.entries[index], let media = mediaForMessage(message: message) { + if case let .MessageEntry(message, _, _) = strongSelf.entries[index], let media = mediaForMessage(message: message) { hiddenItem = (message.id, media) } @@ -284,14 +315,14 @@ class GalleryController: ViewController { var nodeAnimatesItself = false if let centralItemNode = self.galleryNode.pager.centralItemNode(), let presentationArguments = self.presentationArguments as? GalleryControllerPresentationArguments { - if case let .MessageEntry(message, _) = self.entries[centralItemNode.index] { + if case let .MessageEntry(message, _, _) = self.entries[centralItemNode.index] { self.centralItemTitle.set(centralItemNode.title()) self.centralItemTitleView.set(centralItemNode.titleView()) self.centralItemNavigationStyle.set(centralItemNode.navigationStyle()) - if let media = mediaForMessage(message: message), let node = presentationArguments.transitionNode(message.id, media) { + if let media = mediaForMessage(message: message), let transitionArguments = presentationArguments.transitionArguments(message.id, media) { nodeAnimatesItself = true - centralItemNode.animateIn(from: node) + centralItemNode.animateIn(from: transitionArguments.transitionNode) self._hiddenMedia.set(.single((message.id, media))) } diff --git a/TelegramUI/GalleryControllerNode.swift b/TelegramUI/GalleryControllerNode.swift index 0fc0106529..beeba6d575 100644 --- a/TelegramUI/GalleryControllerNode.swift +++ b/TelegramUI/GalleryControllerNode.swift @@ -8,13 +8,23 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { var transitionNodeForCentralItem: (() -> ASDisplayNode?)? var dismiss: (() -> Void)? - var containerLayout: ContainerViewLayout? + var containerLayout: (CGFloat, ContainerViewLayout)? + var backgroundNode: ASDisplayNode var scrollView: UIScrollView var pager: GalleryPagerNode var areControlsHidden = false + var isBackgroundExtendedOverNavigationBar = true { + didSet { + if let (navigationBarHeight, layout) = self.containerLayout { + self.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: self.isBackgroundExtendedOverNavigationBar ? 0.0 : navigationBarHeight), size: CGSize(width: layout.size.width, height: layout.size.height - (self.isBackgroundExtendedOverNavigationBar ? 0.0 : navigationBarHeight))) + } + } + } override init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.backgroundColor = UIColor.black self.scrollView = UIScrollView() self.pager = GalleryPagerNode() @@ -33,6 +43,8 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { } } + self.addSubnode(self.backgroundNode) + self.scrollView.showsVerticalScrollIndicator = false self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.alwaysBounceHorizontal = false @@ -43,12 +55,12 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { self.view.addSubview(self.scrollView) self.scrollView.addSubview(self.pager.view) - - self.backgroundColor = UIColor.black } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { - self.containerLayout = layout + self.containerLayout = (navigationBarHeight, layout) + + transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: self.isBackgroundExtendedOverNavigationBar ? 0.0 : navigationBarHeight), size: CGSize(width: layout.size.width, height: layout.size.height - (self.isBackgroundExtendedOverNavigationBar ? 0.0 : navigationBarHeight)))) let previousContentHeight = self.scrollView.contentSize.height let previousVerticalOffset = self.scrollView.contentOffset.y @@ -67,11 +79,11 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { } func animateIn(animateContent: Bool) { - self.backgroundColor = self.backgroundColor?.withAlphaComponent(0.0) + self.backgroundNode.backgroundColor = self.backgroundNode.backgroundColor?.withAlphaComponent(0.0) self.statusBar?.alpha = 0.0 self.navigationBar?.alpha = 0.0 UIView.animate(withDuration: 0.2, animations: { - self.backgroundColor = self.backgroundColor?.withAlphaComponent(1.0) + self.backgroundNode.backgroundColor = self.backgroundNode.backgroundColor?.withAlphaComponent(1.0) self.statusBar?.alpha = 1.0 self.navigationBar?.alpha = 1.0 }) @@ -92,7 +104,7 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { } UIView.animate(withDuration: 0.25, animations: { - self.backgroundColor = self.backgroundColor?.withAlphaComponent(0.0) + self.backgroundNode.backgroundColor = self.backgroundNode.backgroundColor?.withAlphaComponent(0.0) self.statusBar?.alpha = 0.0 self.navigationBar?.alpha = 0.0 }, completion: { _ in @@ -114,7 +126,7 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { let transition = 1.0 - min(1.0, max(0.0, abs(distanceFromEquilibrium) / 50.0)) let backgroundTransition = 1.0 - min(1.0, max(0.0, abs(distanceFromEquilibrium) / 80.0)) - self.backgroundColor = self.backgroundColor?.withAlphaComponent(backgroundTransition) + self.backgroundNode.backgroundColor = self.backgroundNode.backgroundColor?.withAlphaComponent(backgroundTransition) if !self.areControlsHidden { self.statusBar?.alpha = transition @@ -126,7 +138,7 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { targetContentOffset.pointee = scrollView.contentOffset if abs(velocity.y) > 1.0 { - self.layer.animate(from: self.layer.backgroundColor!, to: UIColor(white: 0.0, alpha: 0.0).cgColor, keyPath: "backgroundColor", timingFunction: kCAMediaTimingFunctionLinear, duration: 0.2, removeOnCompletion: false) + self.backgroundNode.layer.animate(from: self.backgroundNode.backgroundColor!, to: UIColor(white: 0.0, alpha: 0.0).cgColor, keyPath: "backgroundColor", timingFunction: kCAMediaTimingFunctionLinear, duration: 0.2, removeOnCompletion: false) var interfaceAnimationCompleted = false var contentAnimationCompleted = true diff --git a/TelegramUI/GridHoleItem.swift b/TelegramUI/GridHoleItem.swift new file mode 100644 index 0000000000..9b3974c36d --- /dev/null +++ b/TelegramUI/GridHoleItem.swift @@ -0,0 +1,30 @@ +import Foundation +import Display +import AsyncDisplayKit + +class GridHoleItem: GridItem { + func node(layout: GridNodeLayout) -> GridItemNode { + return GridHoleItemNode() + } +} + +class GridHoleItemNode: GridItemNode { + private let activityIndicatorView: UIActivityIndicatorView + + override init() { + self.activityIndicatorView = UIActivityIndicatorView(activityIndicatorStyle: .gray) + + super.init() + + self.view.addSubview(self.activityIndicatorView) + self.activityIndicatorView.startAnimating() + } + + override func layout() { + super.layout() + + let size = self.bounds.size + let activityIndicatorSize = self.activityIndicatorView.bounds.size + self.activityIndicatorView.frame = CGRect(origin: CGPoint(x: floor((size.width - activityIndicatorSize.width) / 2.0), y: floor((size.height - activityIndicatorSize.height) / 2.0)), size: activityIndicatorSize) + } +} diff --git a/TelegramUI/GridMessageItem.swift b/TelegramUI/GridMessageItem.swift new file mode 100644 index 0000000000..cc6fde7f78 --- /dev/null +++ b/TelegramUI/GridMessageItem.swift @@ -0,0 +1,160 @@ +import Foundation +import Display +import AsyncDisplayKit +import TelegramCore +import Postbox + +private func mediaForMessage(_ message: Message) -> Media? { + for media in message.media { + if let media = media as? TelegramMediaImage { + return media + } else if let file = media as? TelegramMediaFile { + if file.mimeType.hasPrefix("audio/") { + return nil + } else if !file.isVideo && file.mimeType.hasPrefix("video/") { + return file + } else { + return file + } + } + } + return nil +} + +final class GridMessageItem: GridItem { + private let account: Account + private let message: Message + private let controllerInteraction: ChatControllerInteraction + + init(account: Account, message: Message, controllerInteraction: ChatControllerInteraction) { + self.account = account + self.message = message + self.controllerInteraction = controllerInteraction + } + + func node(layout: GridNodeLayout) -> GridItemNode { + let node = GridMessageItemNode() + if let media = mediaForMessage(self.message) { + node.setup(account: self.account, media: media, messageId: self.message.id, controllerInteraction: self.controllerInteraction) + } + return node + } +} + +final class GridMessageItemNode: GridItemNode { + private var currentState: (Account, Media, CGSize)? + private let imageNode: TransformImageNode + private var messageId: MessageId? + private var controllerInteraction: ChatControllerInteraction? + + private var selectionNode: GridMessageSelectionNode? + + override init() { + self.imageNode = TransformImageNode() + + super.init() + + self.addSubnode(self.imageNode) + } + + override func didLoad() { + super.didLoad() + + self.imageNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.imageNodeTap(_:)))) + } + + func setup(account: Account, media: Media, messageId: MessageId, controllerInteraction: ChatControllerInteraction) { + if self.currentState == nil || self.currentState!.0 !== account || !self.currentState!.1.isEqual(media) { + var mediaDimensions: CGSize? + if let image = media as? TelegramMediaImage, let largestSize = largestImageRepresentation(image.representations)?.dimensions { + mediaDimensions = largestSize + self.imageNode.setSignal(account: account, signal: mediaGridMessagePhoto(account: account, photo: image), dispatchOnDisplayLink: true) + } + + if let mediaDimensions = mediaDimensions { + self.currentState = (account, media, mediaDimensions) + self.setNeedsLayout() + } + } + + self.messageId = messageId + self.controllerInteraction = controllerInteraction + + self.updateSelectionState(animated: false) + self.updateHiddenMedia() + } + + override func layout() { + super.layout() + + let imageFrame = self.bounds.insetBy(dx: 1.0, dy: 1.0) + self.imageNode.frame = imageFrame + + if let (_, _, mediaDimensions) = self.currentState { + let imageSize = mediaDimensions.aspectFilled(imageFrame.size) + self.imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: imageFrame.size, intrinsicInsets: UIEdgeInsets()))() + } + + self.selectionNode?.frame = CGRect(origin: CGPoint(), size: self.bounds.size) + } + + func updateSelectionState(animated: Bool) { + if let messageId = self.messageId, let controllerInteraction = self.controllerInteraction { + if let selectionState = controllerInteraction.selectionState { + var selected = selectionState.selectedIds.contains(messageId) + + if let selectionNode = self.selectionNode { + selectionNode.updateSelected(selected, animated: animated) + selectionNode.frame = CGRect(origin: CGPoint(), size: self.bounds.size) + } else { + let selectionNode = GridMessageSelectionNode(toggle: { [weak self] in + if let strongSelf = self, let messageId = strongSelf.messageId { + strongSelf.controllerInteraction?.toggleMessageSelection(messageId) + } + }) + + selectionNode.frame = CGRect(origin: CGPoint(), size: self.bounds.size) + self.addSubnode(selectionNode) + self.selectionNode = selectionNode + selectionNode.updateSelected(selected, animated: false) + if animated { + selectionNode.animateIn() + } + } + } else { + if let selectionNode = self.selectionNode { + self.selectionNode = nil + if animated { + selectionNode.animateOut { [weak selectionNode] in + selectionNode?.removeFromSupernode() + } + } else { + selectionNode.removeFromSupernode() + } + } + } + } + } + + func transitionNode(id: MessageId, media: Media) -> ASDisplayNode? { + if self.messageId == id { + return self.imageNode + } else { + return nil + } + } + + func updateHiddenMedia() { + if let controllerInteraction = self.controllerInteraction, let messageId = self.messageId, controllerInteraction.hiddenMedia[messageId] != nil { + self.imageNode.isHidden = true + } else { + self.imageNode.isHidden = false + } + } + + @objc func imageNodeTap(_ recognizer: UITapGestureRecognizer) { + if let controllerInteraction = self.controllerInteraction, let messageId = self.messageId, case .ended = recognizer.state { + controllerInteraction.openMessage(messageId) + } + } +} diff --git a/TelegramUI/GridMessageSelectionNode.swift b/TelegramUI/GridMessageSelectionNode.swift new file mode 100644 index 0000000000..f86e2d549a --- /dev/null +++ b/TelegramUI/GridMessageSelectionNode.swift @@ -0,0 +1,64 @@ +import Foundation +import AsyncDisplayKit +import Display + +private let checkedImage = UIImage(bundleImageName: "Chat/Message/SelectionChecked")?.precomposed() +private let uncheckedImage = UIImage(bundleImageName: "Chat/Message/SelectionUnchecked")?.precomposed() + +final class GridMessageSelectionNode: ASDisplayNode { + private let toggle: () -> Void + + private var selected = false + private let checkNode: ASImageNode + + init(toggle: @escaping () -> Void) { + self.toggle = toggle + self.checkNode = ASImageNode() + self.checkNode.displaysAsynchronously = false + self.checkNode.displayWithoutProcessing = true + self.checkNode.isLayerBacked = true + + super.init() + + self.checkNode.image = uncheckedImage + self.addSubnode(self.checkNode) + } + + override func didLoad() { + super.didLoad() + + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + } + + func animateIn() { + self.checkNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring) + self.checkNode.layer.animateScale(from: 0.2, to: 1.0, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring) + } + + func animateOut(completion: @escaping () -> Void) { + self.checkNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + self.checkNode.layer.animateScale(from: 1.0, to: 0.2, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in + completion() + }) + } + + func updateSelected(_ selected: Bool, animated: Bool) { + if self.selected != selected { + self.selected = selected + self.checkNode.image = selected ? checkedImage : uncheckedImage + } + } + + @objc func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.toggle() + } + } + + override func layout() { + super.layout() + + let checkSize = self.checkNode.measure(CGSize(width: 200.0, height: 200.0)) + self.checkNode.frame = CGRect(origin: CGPoint(x: self.bounds.size.width - checkSize.width - 2.0, y: 2.0), size: checkSize) + } +} diff --git a/TelegramUI/ListController.swift b/TelegramUI/ListController.swift index 395ba7d2dc..7532ff7682 100644 --- a/TelegramUI/ListController.swift +++ b/TelegramUI/ListController.swift @@ -17,7 +17,9 @@ public class ListController: ViewController { self.displayNode.backgroundColor = UIColor(0xefeff4) - self.listDisplayNode.listView.deleteAndInsertItems(deleteIndices: [], insertIndicesAndItems: (0 ..< self.items.count).map({ ListViewInsertItem(index: $0, previousIndex: nil, item: self.items[$0], directionHint: .Down) }), updateIndicesAndItems: [], options: []) + if !self.items.isEmpty { + self.listDisplayNode.listView.deleteAndInsertItems(deleteIndices: [], insertIndicesAndItems: (0 ..< self.items.count).map({ ListViewInsertItem(index: $0, previousIndex: nil, item: self.items[$0], directionHint: .Down) }), updateIndicesAndItems: [], options: [.LowLatency, .Synchronous]) + } self.displayNodeDidLoad() } diff --git a/TelegramUI/ListControllerButtonItem.swift b/TelegramUI/ListControllerButtonItem.swift index 2a6d6442f4..387ed84b00 100644 --- a/TelegramUI/ListControllerButtonItem.swift +++ b/TelegramUI/ListControllerButtonItem.swift @@ -22,7 +22,7 @@ class ListControllerButtonItem: ListControllerGroupableItem { completion(node) } - func selected() { + func selected(listView: ListView) { self.action() } } diff --git a/TelegramUI/ListControllerDisclosureActionItem.swift b/TelegramUI/ListControllerDisclosureActionItem.swift index f464e6eeb0..f5f5cb56e4 100644 --- a/TelegramUI/ListControllerDisclosureActionItem.swift +++ b/TelegramUI/ListControllerDisclosureActionItem.swift @@ -31,7 +31,7 @@ class ListControllerDisclosureActionItem: ListControllerGroupableItem { completion(node) } - func selected() { + func selected(listView: ListView) { self.action() } } diff --git a/TelegramUI/ListControllerNode.swift b/TelegramUI/ListControllerNode.swift index b4ae02b8ae..d354939e72 100644 --- a/TelegramUI/ListControllerNode.swift +++ b/TelegramUI/ListControllerNode.swift @@ -8,7 +8,9 @@ public class ListControllerNode: ASDisplayNode { override init() { self.listView = ListView() - super.init() + super.init(viewBlock: { + return UITracingLayerView() + }, didLoad: nil) self.addSubnode(self.listView) } @@ -29,11 +31,19 @@ public class ListControllerNode: ASDisplayNode { } } + let listViewCurve: ListViewAnimationCurve + if curve == 7 { + listViewCurve = .Spring(duration: duration) + } else { + listViewCurve = .Default + } + var insets = layout.insets(options: [.input]) insets.top += navigationBarHeight self.listView.bounds = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) self.listView.position = CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0) - self.listView.updateSizeAndInsets(size: layout.size, insets: UIEdgeInsets(top: insets.top, left: insets.left, bottom: insets.bottom, right: insets.right), duration: duration, options: UIViewAnimationOptions(rawValue: curve << 16)) + + self.listView.deleteAndInsertItems(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: insets, duration: duration, curve: listViewCurve), stationaryItemRange: nil, completion: { _ in }) } } diff --git a/TelegramUI/ListMessageFileItemNode.swift b/TelegramUI/ListMessageFileItemNode.swift new file mode 100644 index 0000000000..fe537b2343 --- /dev/null +++ b/TelegramUI/ListMessageFileItemNode.swift @@ -0,0 +1,531 @@ +import Foundation +import AsyncDisplayKit +import Display +import Postbox +import TelegramCore +import SwiftSignalKit + +private let extensionImageCache = Atomic<[UInt32: UIImage]>(value: [:]) + +private let redColors: (UInt32, UInt32) = (0xf0625d, 0xde524e) +private let greenColors: (UInt32, UInt32) = (0x72ce76, 0x54b658) +private let blueColors: (UInt32, UInt32) = (0x60b0e8, 0x4597d1) +private let yellowColors: (UInt32, UInt32) = (0xf5c565, 0xe5a64e) + +private let extensionColorsMap: [String: (UInt32, UInt32)] = [ + "ppt": redColors, + "pptx": redColors, + "pdf": redColors, + "key": redColors, + + "xls": greenColors, + "xlsx": greenColors, + "csv": greenColors, + + "zip": yellowColors, + "rar": yellowColors, + "gzip": yellowColors, + "ai": yellowColors +] + +private func generateExtensionImage(colors: (UInt32, UInt32)) -> UIImage? { + return generateImage(CGSize(width: 42.0, height: 42.0), contextGenerator: { 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) + context.translateBy(x: -size.width / 2.0 + 1.0, y: -size.height / 2.0 + 1.0) + + let radius: CGFloat = 2.0 + let cornerSize: CGFloat = 10.0 + let size = CGSize(width: 42.0, height: 42.0) + + context.setFillColor(UIColor(colors.0).cgColor) + context.beginPath() + context.move(to: CGPoint(x: 0.0, y: radius)) + if !radius.isZero { + context.addArc(tangent1End: CGPoint(x: 0.0, y: 0.0), tangent2End: CGPoint(x: radius, y: 0.0), radius: radius) + } + context.addLine(to: CGPoint(x: size.width - cornerSize, y: 0.0)) + context.addLine(to: CGPoint(x: size.width - cornerSize + cornerSize / 4.0, y: cornerSize - cornerSize / 4.0)) + context.addLine(to: CGPoint(x: size.width, y: cornerSize)) + context.addLine(to: CGPoint(x: size.width, y: size.height - radius)) + if !radius.isZero { + context.addArc(tangent1End: CGPoint(x: size.width, y: size.height), tangent2End: CGPoint(x: size.width - radius, y: size.height), radius: radius) + } + context.addLine(to: CGPoint(x: radius, y: size.height)) + + if !radius.isZero { + context.addArc(tangent1End: CGPoint(x: 0.0, y: size.height), tangent2End: CGPoint(x: 0.0, y: size.height - radius), radius: radius) + } + context.closePath() + context.fillPath() + + context.setFillColor(UIColor(colors.1).cgColor) + context.beginPath() + context.move(to: CGPoint(x: size.width - cornerSize, y: 0.0)) + context.addLine(to: CGPoint(x: size.width, y: cornerSize)) + context.addLine(to: CGPoint(x: size.width - cornerSize + radius, y: cornerSize)) + + if !radius.isZero { + context.addArc(tangent1End: CGPoint(x: size.width - cornerSize, y: cornerSize), tangent2End: CGPoint(x: size.width - cornerSize, y: cornerSize - radius), radius: radius) + } + + context.closePath() + context.fillPath() + }) +} + +private func extensionImage(fileExtension: String?) -> UIImage? { + let colors: (UInt32, UInt32) + if let fileExtension = fileExtension { + if let extensionColors = extensionColorsMap[fileExtension] { + colors = extensionColors + } else { + colors = blueColors + } + } else { + colors = blueColors + } + + if let cachedImage = (extensionImageCache.with { dict in + return dict[colors.0] + }) { + return cachedImage + } else if let image = generateExtensionImage(colors: colors) { + let _ = extensionImageCache.modify { dict in + var dict = dict + dict[colors.0] = image + return dict + } + return image + } else { + return nil + } +} + +private let titleFont = Font.medium(16.0) +private let descriptionFont = Font.regular(13.0) +private let extensionFont = Font.medium(13.0) + +private let downloadFileStartIcon = generateTintedImage(image: UIImage(bundleImageName: "List Menu/ListDownloadStartIcon"), color: UIColor(0x1195f2)) +private let downloadFilePauseIcon = generateImage(CGSize(width: 11.0, height: 11.0), contextGenerator: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + + context.setFillColor(UIColor(0x1195f2).cgColor) + + context.fill(CGRect(x: 2.0, y: 0.0, width: 2.0, height: 11.0 - 1.0)) + context.fill(CGRect(x: 2.0 + 2.0 + 2.0, y: 0.0, width: 2.0, height: 11.0 - 1.0)) +}) + +private struct FetchControls { + let fetch: () -> Void + let cancel: () -> Void +} + +final class ListMessageFileItemNode: ListMessageNode { + private let highlightedBackgroundNode: ASDisplayNode + private let separatorNode: ASDisplayNode + private let titleNode: TextNode + private let descriptionNode: TextNode + + private let extensionIconNode: ASImageNode + private let extensionIconText: TextNode + private let iconImageNode: TransformImageNode + + private var currentIconImageRepresentation: TelegramMediaImageRepresentation? + private var currentMedia: Media? + + private let statusDisposable = MetaDisposable() + private let fetchControls = Atomic(value: nil) + private var fetchStatus: MediaResourceStatus? + private let fetchDisposable = MetaDisposable() + + private var downloadStatusIconNode: ASImageNode + private var progressNode: ASDisplayNode + + public required init() { + self.separatorNode = ASDisplayNode() + self.separatorNode.backgroundColor = UIColor(0xc8c7cc) + self.separatorNode.displaysAsynchronously = false + self.separatorNode.isLayerBacked = true + + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.backgroundColor = UIColor(0xd9d9d9) + self.highlightedBackgroundNode.isLayerBacked = true + + self.titleNode = TextNode() + self.titleNode.isLayerBacked = true + + self.descriptionNode = TextNode() + self.descriptionNode.isLayerBacked = true + + self.extensionIconNode = ASImageNode() + self.extensionIconNode.isLayerBacked = true + self.extensionIconNode.displaysAsynchronously = false + self.extensionIconNode.displayWithoutProcessing = true + + self.extensionIconText = TextNode() + self.extensionIconText.isLayerBacked = true + + self.iconImageNode = TransformImageNode() + self.iconImageNode.displaysAsynchronously = false + + self.downloadStatusIconNode = ASImageNode() + self.downloadStatusIconNode.isLayerBacked = true + self.downloadStatusIconNode.displaysAsynchronously = false + self.downloadStatusIconNode.displayWithoutProcessing = true + + self.progressNode = ASDisplayNode() + self.progressNode.backgroundColor = UIColor(0x1195f2) + self.progressNode.isLayerBacked = true + + super.init() + + self.addSubnode(self.separatorNode) + self.addSubnode(self.titleNode) + self.addSubnode(self.descriptionNode) + self.addSubnode(self.extensionIconNode) + self.addSubnode(self.extensionIconText) + } + + deinit { + self.statusDisposable.dispose() + self.fetchDisposable.dispose() + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupItem(_ item: ListMessageItem) { + self.item = item + } + + override public func layoutForWidth(_ width: CGFloat, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { + if let item = item as? ListMessageItem { + let doLayout = self.asyncLayout() + let merged = (top: false, bottom: false)//item.mergedWithItems(top: previousItem, bottom: nextItem) + let (layout, apply) = doLayout(item, width, merged.top, merged.bottom) + self.contentSize = layout.contentSize + self.insets = layout.insets + apply(.None) + } + } + + override public func animateInsertion(_ currentTimestamp: Double, duration: Double) { + super.animateInsertion(currentTimestamp, duration: duration) + + self.transitionOffset = self.bounds.size.height * 1.6 + self.addTransitionOffsetAnimation(0.0, duration: duration, beginAt: currentTimestamp) + //self.layer.animateBoundsOriginYAdditive(from: -self.bounds.size.height * 1.4, to: 0.0, duration: duration) + } + + override func asyncLayout() -> (_ item: ListMessageItem, _ width: CGFloat, _ mergedTop: Bool, _ mergedBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { + let titleNodeMakeLayout = TextNode.asyncLayout(self.titleNode) + let descriptionNodeMakeLayout = TextNode.asyncLayout(self.descriptionNode) + let extensionIconTextMakeLayout = TextNode.asyncLayout(self.extensionIconText) + let iconImageLayout = self.iconImageNode.asyncLayout() + + let currentMedia = self.currentMedia + let currentIconImageRepresentation = self.currentIconImageRepresentation + + return { [weak self] item, width, _, _ in + let leftInset: CGFloat = 65.0 + + var extensionIconImage: UIImage? + var title: NSAttributedString? + var descriptionText: NSAttributedString? + var extensionText: NSAttributedString? + + var iconImageRepresentation: TelegramMediaImageRepresentation? + var updateIconImageSignal: Signal<(TransformImageArguments) -> DrawingContext, NoError>? + var updatedStatusSignal: Signal? + var updatedFetchControls: FetchControls? + + var selectedMedia: TelegramMediaFile? + for media in item.message.media { + if let file = media as? TelegramMediaFile { + selectedMedia = file + + let fileName: String = file.fileName ?? "" + title = NSAttributedString(string: fileName, font: titleFont, textColor: UIColor.black) + + var fileExtension: String? + if let range = fileName.range(of: ".", options: [.backwards]) { + fileExtension = fileName.substring(from: range.upperBound).lowercased() + } + extensionIconImage = extensionImage(fileExtension: fileExtension) + if let fileExtension = fileExtension { + extensionText = NSAttributedString(string: fileExtension, font: extensionFont, textColor: UIColor.white) + } + + iconImageRepresentation = smallestImageRepresentation(file.previewRepresentations) + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMM d, yyyy 'at' h a" + + let dateString = dateFormatter.string(from: Date(timeIntervalSince1970: Double(item.message.timestamp))) + + descriptionText = NSAttributedString(string: "\(dataSizeString(file.size)) • \(dateString)", font: descriptionFont, textColor: UIColor(0xa8a8a8)) + + break + } + } + + var mediaUpdated = false + if let currentMedia = currentMedia { + if let selectedMedia = selectedMedia { + mediaUpdated = !selectedMedia.isEqual(currentMedia) + } else { + mediaUpdated = true + } + } else { + mediaUpdated = selectedMedia != nil + } + + if let selectedMedia = selectedMedia, mediaUpdated { + let account = item.account + updatedStatusSignal = chatMessageFileStatus(account: account, file: selectedMedia) + updatedFetchControls = FetchControls(fetch: { [weak self] in + if let strongSelf = self { + strongSelf.fetchDisposable.set(chatMessageFileInteractiveFetched(account: account, file: selectedMedia).start()) + } + }, cancel: { + chatMessageFileCancelInteractiveFetch(account: account, file: selectedMedia) + }) + } + + let (titleNodeLayout, titleNodeApply) = titleNodeMakeLayout(title, nil, 1, .middle, CGSize(width: width - leftInset - 8.0, height: CGFloat.infinity), nil) + + let (descriptionNodeLayout, descriptionNodeApply) = descriptionNodeMakeLayout(descriptionText, nil, 1, .end, CGSize(width: width - leftInset - 8.0 - 12.0, height: CGFloat.infinity), nil) + + let (extensionTextLayout, extensionTextApply) = extensionIconTextMakeLayout(extensionText, nil, 1, .end, CGSize(width: 38.0, height: CGFloat.infinity), nil) + + var iconImageApply: (() -> Void)? + if let iconImageRepresentation = iconImageRepresentation { + let iconSize = CGSize(width: 42.0, height: 42.0) + let imageCorners = ImageCorners(topLeft: .Corner(4.0), topRight: .Corner(4.0), bottomLeft: .Corner(4.0), bottomRight: .Corner(4.0)) + let arguments = TransformImageArguments(corners: imageCorners, imageSize: iconImageRepresentation.dimensions.aspectFilled(iconSize), boundingSize: iconSize, intrinsicInsets: UIEdgeInsets()) + iconImageApply = iconImageLayout(arguments) + } + + if currentIconImageRepresentation != iconImageRepresentation { + if let iconImageRepresentation = iconImageRepresentation { + let tmpImage = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: [iconImageRepresentation]) + updateIconImageSignal = chatWebpageSnippetPhoto(account: item.account, photo: tmpImage) + } else { + updateIconImageSignal = .complete() + } + } + + return (ListViewItemNodeLayout(contentSize: CGSize(width: width, height: 52.0), insets: UIEdgeInsets()), { _ in + if let strongSelf = self { + strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 52.0 - UIScreenPixel), size: CGSize(width: width - leftInset, height: UIScreenPixel)) + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: width, height: 52.0 + UIScreenPixel)) + + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 9.0), size: titleNodeLayout.size) + let _ = titleNodeApply() + + var descriptionOffset: CGFloat = 0.0 + if let fetchStatus = strongSelf.fetchStatus { + switch fetchStatus { + case .Remote, .Fetching: + descriptionOffset = 14.0 + case .Local: + break + } + } + + strongSelf.descriptionNode.frame = CGRect(origin: CGPoint(x: leftInset + descriptionOffset, y: 29.0), size: descriptionNodeLayout.size) + let _ = descriptionNodeApply() + + let iconFrame = CGRect(origin: CGPoint(x: 9.0, y: 5.0), size: CGSize(width: 42.0, height: 42.0)) + strongSelf.extensionIconNode.frame = iconFrame + strongSelf.extensionIconNode.image = extensionIconImage + strongSelf.extensionIconText.frame = CGRect(origin: CGPoint(x: 9.0 + floor((42.0 - extensionTextLayout.size.width) / 2.0), y: 5.0 + floor((42.0 - extensionTextLayout.size.height) / 2.0)), size: extensionTextLayout.size) + + let _ = extensionTextApply() + + strongSelf.currentIconImageRepresentation = iconImageRepresentation + + if let iconImageApply = iconImageApply { + if let updateImageSignal = updateIconImageSignal { + strongSelf.iconImageNode.setSignal(account: item.account, signal: updateImageSignal) + } + + strongSelf.iconImageNode.frame = iconFrame + if strongSelf.iconImageNode.supernode == nil { + strongSelf.addSubnode(strongSelf.iconImageNode) + } + + iconImageApply() + + if strongSelf.extensionIconNode.supernode != nil { + strongSelf.extensionIconNode.removeFromSupernode() + } + if strongSelf.extensionIconText.supernode != nil { + strongSelf.extensionIconText.removeFromSupernode() + } + } else if strongSelf.iconImageNode.supernode != nil { + strongSelf.iconImageNode.removeFromSupernode() + + if strongSelf.extensionIconNode.supernode == nil { + strongSelf.addSubnode(strongSelf.extensionIconNode) + } + if strongSelf.extensionIconText.supernode == nil { + strongSelf.addSubnode(strongSelf.extensionIconText) + } + } + + if let updatedStatusSignal = updatedStatusSignal { + strongSelf.statusDisposable.set((updatedStatusSignal |> deliverOnMainQueue).start(next: { [weak strongSelf] status in + displayLinkDispatcher.dispatch { + if let strongSelf = strongSelf { + strongSelf.fetchStatus = status + + strongSelf.updateProgressFrame(size: strongSelf.bounds.size) + } + } + })) + } + + strongSelf.updateProgressFrame(size: CGSize(width: width, height: 52.0)) + strongSelf.downloadStatusIconNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 32.0), size: CGSize(width: 11.0, height: 11.0)) + + if let updatedFetchControls = updatedFetchControls { + let _ = strongSelf.fetchControls.swap(updatedFetchControls) + } + } + }) + } + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + if highlighted { + self.highlightedBackgroundNode.alpha = 1.0 + if self.highlightedBackgroundNode.supernode == nil { + self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode) + } + } else { + if self.highlightedBackgroundNode.supernode != nil { + if animated { + self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in + if let strongSelf = self { + if completed { + strongSelf.highlightedBackgroundNode.removeFromSupernode() + } + } + }) + self.highlightedBackgroundNode.alpha = 0.0 + } else { + self.highlightedBackgroundNode.removeFromSupernode() + } + } + } + } + + override func transitionNode(id: MessageId, media: Media) -> ASDisplayNode? { + if let item = self.item, item.message.id == id, self.iconImageNode.supernode != nil { + return self.iconImageNode + } + return nil + } + + override func updateHiddenMedia() { + if let controllerInteraction = self.controllerInteraction, let item = self.item, controllerInteraction.hiddenMedia[item.message.id] != nil { + self.iconImageNode.isHidden = true + } else { + self.iconImageNode.isHidden = false + } + } + + override func updateSelectionState(animated: Bool) { + } + + private func updateProgressFrame(size: CGSize) { + var descriptionOffset: CGFloat = 0.0 + + if let fetchStatus = self.fetchStatus { + switch fetchStatus { + case .Remote, .Fetching: + descriptionOffset = 14.0 + case .Local: + break + } + + switch fetchStatus { + case let .Fetching(progress): + let progressFrame = CGRect(x: 65.0, y: size.height - 2.0, width: floor((size.width - 65.0) * CGFloat(progress)), height: 2.0) + if self.progressNode.supernode == nil { + self.addSubnode(self.progressNode) + } + if !self.progressNode.frame.equalTo(progressFrame) { + self.progressNode.frame = progressFrame + } + if self.downloadStatusIconNode.supernode == nil { + self.addSubnode(self.downloadStatusIconNode) + } + self.downloadStatusIconNode.image = downloadFilePauseIcon + case .Local: + if self.progressNode.supernode != nil { + self.progressNode.removeFromSupernode() + } + if self.downloadStatusIconNode.supernode != nil { + self.downloadStatusIconNode.removeFromSupernode() + } + self.downloadStatusIconNode.image = nil + case .Remote: + if self.progressNode.supernode != nil { + self.progressNode.removeFromSupernode() + } + if self.downloadStatusIconNode.supernode == nil { + self.addSubnode(self.downloadStatusIconNode) + } + self.downloadStatusIconNode.image = downloadFileStartIcon + } + } else { + if self.progressNode.supernode != nil { + self.progressNode.removeFromSupernode() + } + if self.downloadStatusIconNode.supernode != nil { + self.downloadStatusIconNode.removeFromSupernode() + } + } + + var descriptionFrame = self.descriptionNode.frame + if !descriptionFrame.origin.x.isEqual(to: 65.0 + descriptionOffset) { + descriptionFrame.origin.x = 65.0 + descriptionOffset + self.descriptionNode.frame = descriptionFrame + } + } + + func activateMedia() { + if let fetchStatus = self.fetchStatus, case .Local = fetchStatus { + if let item = self.item, let controllerInteraction = self.controllerInteraction { + controllerInteraction.openMessage(item.message.id) + } + } else { + self.progressPressed() + } + } + + func progressPressed() { + if let fetchStatus = self.fetchStatus { + switch fetchStatus { + case .Fetching: + if let cancel = self.fetchControls.with({ return $0?.cancel }) { + cancel() + } + case .Remote: + if let fetch = self.fetchControls.with({ return $0?.fetch }) { + fetch() + } + case .Local: + break + } + } + } +} diff --git a/TelegramUI/ListMessageItem.swift b/TelegramUI/ListMessageItem.swift new file mode 100644 index 0000000000..0f09b6d665 --- /dev/null +++ b/TelegramUI/ListMessageItem.swift @@ -0,0 +1,98 @@ +import Foundation +import AsyncDisplayKit +import Display +import TelegramCore +import SwiftSignalKit +import Postbox + +final class ListMessageItem: ListViewItem { + let account: Account + let peerId: PeerId + let controllerInteraction: ChatControllerInteraction + let message: Message + + let selectable: Bool = true + + public init(account: Account, peerId: PeerId, controllerInteraction: ChatControllerInteraction, message: Message) { + self.account = account + self.peerId = peerId + self.controllerInteraction = controllerInteraction + self.message = message + } + + public func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> Void) -> Void) { + var viewClassName: AnyClass = ListMessageFileItemNode.self + + for media in message.media { + if let _ = media as? TelegramMediaWebpage { + viewClassName = ListMessageSnippetItemNode.self + break + } + } + + let configure = { () -> Void in + let node = (viewClassName as! ListMessageNode.Type).init() + node.controllerInteraction = self.controllerInteraction + node.setupItem(self) + + let nodeLayout = node.asyncLayout() + let (top, bottom) = (false, false) //self.mergedWithItems(top: previousItem, bottom: nextItem) + let (layout, apply) = nodeLayout(self, width, top, bottom) + + node.updateSelectionState(animated: false) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + completion(node, { + apply(.None) + }) + } + if Thread.isMainThread { + async { + configure() + } + } else { + configure() + } + } + + public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { + if let node = node as? ListMessageFileItemNode { + Queue.mainQueue().async { + node.setupItem(self) + + node.updateSelectionState(animated: false) + + let nodeLayout = node.asyncLayout() + + async { + let (top, bottom) = (false, false) //self.mergedWithItems(top: previousItem, bottom: nextItem) + + let (layout, apply) = nodeLayout(self, width, top, bottom) + Queue.mainQueue().async { + completion(layout, { + apply(animation) + }) + } + } + } + } + } + + func selected(listView: ListView) { + listView.clearHighlightAnimated(true) + + listView.forEachItemNode { itemNode in + if let itemNode = itemNode as? ListMessageFileItemNode { + if let messageId = itemNode.item?.message.id, messageId == self.message.id { + itemNode.activateMedia() + } + } + } + } + + public var description: String { + return "(ListMessageItem id: \(self.message.id), text: \"\(self.message.text)\")" + } +} diff --git a/TelegramUI/ListMessageNode.swift b/TelegramUI/ListMessageNode.swift new file mode 100644 index 0000000000..67f99dcf31 --- /dev/null +++ b/TelegramUI/ListMessageNode.swift @@ -0,0 +1,38 @@ +import Foundation +import Display +import AsyncDisplayKit +import Postbox + +class ListMessageNode: ListViewItemNode { + var item: ListMessageItem? + var controllerInteraction: ChatControllerInteraction? + + required init() { + super.init(layerBacked: false, dynamicBounce: false) + } + + func setupItem(_ item: ListMessageItem) { + self.item = item + } + + override public func layoutForWidth(_ width: CGFloat, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { + } + + func asyncLayout() -> (_ item: ListMessageItem, _ width: CGFloat, _ mergedTop: Bool, _ mergedBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { + return { _, width, _, _ in + return (ListViewItemNodeLayout(contentSize: CGSize(width: width, height: 1.0), insets: UIEdgeInsets()), { _ in + + }) + } + } + + func transitionNode(id: MessageId, media: Media) -> ASDisplayNode? { + return nil + } + + func updateHiddenMedia() { + } + + func updateSelectionState(animated: Bool) { + } +} diff --git a/TelegramUI/ListMessageSnippetItemNode.swift b/TelegramUI/ListMessageSnippetItemNode.swift new file mode 100644 index 0000000000..caa97848a7 --- /dev/null +++ b/TelegramUI/ListMessageSnippetItemNode.swift @@ -0,0 +1,270 @@ +import Foundation +import AsyncDisplayKit +import Display +import Postbox +import TelegramCore +import SwiftSignalKit + +private let titleFont = Font.medium(16.0) +private let descriptionFont = Font.regular(14.0) +private let iconFont = Font.medium(22.0) + +private let iconTextBackgroundImage = generateStretchableFilledCircleImage(radius: 2.0, color: UIColor(0xdfdfdf)) + +final class ListMessageSnippetItemNode: ListMessageNode { + private let highlightedBackgroundNode: ASDisplayNode + private let separatorNode: ASDisplayNode + private let titleNode: TextNode + private let descriptionNode: TextNode + + private let iconTextBackgroundNode: ASImageNode + private let iconTextNode: TextNode + private let iconImageNode: TransformImageNode + + private var currentIconImageRepresentation: TelegramMediaImageRepresentation? + private var currentMedia: Media? + + public required init() { + self.separatorNode = ASDisplayNode() + self.separatorNode.backgroundColor = UIColor(0xc8c7cc) + self.separatorNode.displaysAsynchronously = false + self.separatorNode.isLayerBacked = true + + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.backgroundColor = UIColor(0xd9d9d9) + self.highlightedBackgroundNode.isLayerBacked = true + + self.titleNode = TextNode() + self.titleNode.isLayerBacked = true + + self.descriptionNode = TextNode() + self.descriptionNode.isLayerBacked = true + + self.iconTextBackgroundNode = ASImageNode() + self.iconTextBackgroundNode.isLayerBacked = true + self.iconTextBackgroundNode.displaysAsynchronously = false + self.iconTextBackgroundNode.displayWithoutProcessing = true + + self.iconTextNode = TextNode() + self.iconTextNode.isLayerBacked = true + + self.iconImageNode = TransformImageNode() + self.iconImageNode.isLayerBacked = true + self.iconImageNode.displaysAsynchronously = false + + super.init() + + self.addSubnode(self.separatorNode) + self.addSubnode(self.titleNode) + self.addSubnode(self.descriptionNode) + self.addSubnode(self.iconImageNode) + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupItem(_ item: ListMessageItem) { + self.item = item + } + + override public func layoutForWidth(_ width: CGFloat, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { + if let item = item as? ListMessageItem { + let doLayout = self.asyncLayout() + let merged = (top: false, bottom: false)//item.mergedWithItems(top: previousItem, bottom: nextItem) + let (layout, apply) = doLayout(item, width, merged.top, merged.bottom) + self.contentSize = layout.contentSize + self.insets = layout.insets + apply(.None) + } + } + + override public func animateInsertion(_ currentTimestamp: Double, duration: Double) { + super.animateInsertion(currentTimestamp, duration: duration) + + self.transitionOffset = self.bounds.size.height * 1.6 + self.addTransitionOffsetAnimation(0.0, duration: duration, beginAt: currentTimestamp) + //self.layer.animateBoundsOriginYAdditive(from: -self.bounds.size.height * 1.4, to: 0.0, duration: duration) + } + + override func asyncLayout() -> (_ item: ListMessageItem, _ width: CGFloat, _ mergedTop: Bool, _ mergedBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { + let titleNodeMakeLayout = TextNode.asyncLayout(self.titleNode) + let descriptionNodeMakeLayout = TextNode.asyncLayout(self.descriptionNode) + let iconTextMakeLayout = TextNode.asyncLayout(self.iconTextNode) + let iconImageLayout = self.iconImageNode.asyncLayout() + + let currentMedia = self.currentMedia + let currentIconImageRepresentation = self.currentIconImageRepresentation + + return { [weak self] item, width, _, _ in + let leftInset: CGFloat = 65.0 + + var extensionIconImage: UIImage? + var title: NSAttributedString? + var descriptionText: NSAttributedString? + var iconText: NSAttributedString? + + var iconImageRepresentation: TelegramMediaImageRepresentation? + var updateIconImageSignal: Signal<(TransformImageArguments) -> DrawingContext, NoError>? + + let applyIconTextBackgroundImage = iconTextBackgroundImage + + var selectedMedia: TelegramMediaWebpage? + for media in item.message.media { + if let webpage = media as? TelegramMediaWebpage { + selectedMedia = webpage + + if case let .Loaded(content) = webpage.content { + var hostName: String = "" + if let url = URL(string: content.url), let host = url.host, !host.isEmpty { + hostName = host + iconText = NSAttributedString(string: host.substring(to: host.index(after: host.startIndex)).uppercased(), font: iconFont, textColor: UIColor.white) + } + + title = NSAttributedString(string: content.title ?? content.websiteName ?? hostName, font: titleFont, textColor: UIColor.black) + + if let image = content.image { + iconImageRepresentation = smallestImageRepresentation(image.representations) + } else if let file = content.file { + iconImageRepresentation = smallestImageRepresentation(file.previewRepresentations) + } + + let mutableDescriptionText = NSMutableAttributedString() + if let text = content.text { + mutableDescriptionText.append(NSAttributedString(string: text + "\n", font: descriptionFont, textColor: UIColor.black)) + } + + mutableDescriptionText.append(NSAttributedString(string: content.displayUrl, font: descriptionFont, textColor: UIColor(0x1195f2))) + + let style = NSMutableParagraphStyle() + style.lineSpacing = 4.0 + mutableDescriptionText.addAttribute(NSParagraphStyleAttributeName, value: style, range: NSMakeRange(0, mutableDescriptionText.length)) + + descriptionText = mutableDescriptionText + } + + break + } + } + + let (titleNodeLayout, titleNodeApply) = titleNodeMakeLayout(title, nil, 1, .middle, CGSize(width: width - leftInset - 8.0, height: CGFloat.infinity), nil) + + let (descriptionNodeLayout, descriptionNodeApply) = descriptionNodeMakeLayout(descriptionText, nil, 0, .end, CGSize(width: width - leftInset - 8.0 - 12.0, height: CGFloat.infinity), nil) + + let (iconTextLayout, iconTextApply) = iconTextMakeLayout(iconText, nil, 1, .end, CGSize(width: 38.0, height: CGFloat.infinity), nil) + + var iconImageApply: (() -> Void)? + if let iconImageRepresentation = iconImageRepresentation { + let iconSize = CGSize(width: 42.0, height: 42.0) + let imageCorners = ImageCorners(topLeft: .Corner(2.0), topRight: .Corner(2.0), bottomLeft: .Corner(2.0), bottomRight: .Corner(2.0)) + let arguments = TransformImageArguments(corners: imageCorners, imageSize: iconImageRepresentation.dimensions.aspectFilled(iconSize), boundingSize: iconSize, intrinsicInsets: UIEdgeInsets()) + iconImageApply = iconImageLayout(arguments) + } + + if currentIconImageRepresentation != iconImageRepresentation { + if let iconImageRepresentation = iconImageRepresentation { + let tmpImage = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: [iconImageRepresentation]) + updateIconImageSignal = chatWebpageSnippetPhoto(account: item.account, photo: tmpImage) + } else { + updateIconImageSignal = .complete() + } + } + + let contentHeight = 39.0 + descriptionNodeLayout.size.height + + return (ListViewItemNodeLayout(contentSize: CGSize(width: width, height: contentHeight), insets: UIEdgeInsets()), { _ in + if let strongSelf = self { + strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentHeight - UIScreenPixel), size: CGSize(width: width - leftInset, height: UIScreenPixel)) + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: width, height: contentHeight + UIScreenPixel)) + + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 9.0), size: titleNodeLayout.size) + let _ = titleNodeApply() + + strongSelf.descriptionNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 29.0), size: descriptionNodeLayout.size) + let _ = descriptionNodeApply() + + let iconFrame = CGRect(origin: CGPoint(x: 9.0, y: 12.0), size: CGSize(width: 42.0, height: 42.0)) + strongSelf.iconTextNode.frame = CGRect(origin: CGPoint(x: iconFrame.minX + floor((42.0 - iconTextLayout.size.width) / 2.0), y: iconFrame.minY + floor((42.0 - iconTextLayout.size.height) / 2.0) + 3.0), size: iconTextLayout.size) + + let _ = iconTextApply() + + strongSelf.currentIconImageRepresentation = iconImageRepresentation + + if let iconImageApply = iconImageApply { + if let updateImageSignal = updateIconImageSignal { + strongSelf.iconImageNode.setSignal(account: item.account, signal: updateImageSignal) + } + + if strongSelf.iconImageNode.supernode == nil { + strongSelf.addSubnode(strongSelf.iconImageNode) + } + + strongSelf.iconImageNode.frame = iconFrame + + iconImageApply() + + if strongSelf.iconTextBackgroundNode.supernode != nil { + strongSelf.iconTextBackgroundNode.removeFromSupernode() + } + if strongSelf.iconTextNode.supernode != nil { + strongSelf.iconTextNode.removeFromSupernode() + } + } else if strongSelf.iconImageNode.supernode != nil { + strongSelf.iconImageNode.removeFromSupernode() + + if strongSelf.iconTextBackgroundNode.supernode == nil { + strongSelf.iconTextBackgroundNode.image = applyIconTextBackgroundImage + strongSelf.addSubnode(strongSelf.iconTextBackgroundNode) + } + strongSelf.iconTextBackgroundNode.frame = iconFrame + if strongSelf.iconTextNode.supernode == nil { + strongSelf.addSubnode(strongSelf.iconTextNode) + } + } + } + }) + } + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + if highlighted { + self.highlightedBackgroundNode.alpha = 1.0 + if self.highlightedBackgroundNode.supernode == nil { + self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode) + } + } else { + if self.highlightedBackgroundNode.supernode != nil { + if animated { + self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in + if let strongSelf = self { + if completed { + strongSelf.highlightedBackgroundNode.removeFromSupernode() + } + } + }) + self.highlightedBackgroundNode.alpha = 0.0 + } else { + self.highlightedBackgroundNode.removeFromSupernode() + } + } + } + } + + override func transitionNode(id: MessageId, media: Media) -> ASDisplayNode? { + return nil + } + + override func updateHiddenMedia() { + } + + override func updateSelectionState(animated: Bool) { + } + + func activateMedia() { + if let webpage = self.currentMedia as? TelegramMediaWebpage { + + } + } +} diff --git a/TelegramUI/PeerInfoActionItem.swift b/TelegramUI/PeerInfoActionItem.swift new file mode 100644 index 0000000000..eeed1e2966 --- /dev/null +++ b/TelegramUI/PeerInfoActionItem.swift @@ -0,0 +1,141 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit + +enum PeerInfoActionKind { + case generic + case destructive +} + +class PeerInfoActionItem: ListViewItem, PeerInfoItem { + let title: String + let kind: PeerInfoActionKind + let sectionId: PeerInfoItemSectionId + let action: () -> Void + + init(title: String, kind: PeerInfoActionKind, sectionId: PeerInfoItemSectionId, action: @escaping () -> Void) { + self.title = title + self.kind = kind + self.sectionId = sectionId + self.action = action + } + + func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> Void) -> Void) { + async { + let node = PeerInfoActionItemNode() + let (layout, apply) = node.asyncLayout()(self, width, peerInfoItemInsets(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + completion(node, { + apply() + }) + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { + if let node = node as? PeerInfoActionItemNode { + Queue.mainQueue().async { + let makeLayout = node.asyncLayout() + + async { + let (layout, apply) = makeLayout(self, width, peerInfoItemInsets(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + Queue.mainQueue().async { + completion(layout, { + apply() + }) + } + } + } + } + } + + var selectable: Bool = true + + func selected(listView: ListView){ + listView.clearHighlightAnimated(true) + self.action() + } +} + +private let titleFont = Font.regular(17.0) + +class PeerInfoActionItemNode: ListViewItemNode { + let titleNode: TextNode + let separatorNode: ASDisplayNode + private let highlightedBackgroundNode: ASDisplayNode + + init() { + self.titleNode = TextNode() + self.titleNode.isLayerBacked = true + self.titleNode.contentMode = .left + self.titleNode.contentsScale = UIScreen.main.scale + + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.backgroundColor = UIColor(0xd9d9d9) + self.highlightedBackgroundNode.isLayerBacked = true + + self.separatorNode = ASDisplayNode() + self.separatorNode.isLayerBacked = true + self.separatorNode.displaysAsynchronously = false + self.separatorNode.backgroundColor = UIColor(0xc8c7cc) + + super.init(layerBacked: false, dynamicBounce: false) + + self.addSubnode(self.separatorNode) + self.addSubnode(self.titleNode) + } + + func asyncLayout() -> (_ item: PeerInfoActionItem, _ width: CGFloat, _ insets: UIEdgeInsets) -> (ListViewItemNodeLayout, () -> Void) { + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + + return { item, width, insets in + let sectionInset: CGFloat = 22.0 + + let (titleLayout, titleApply) = makeTitleLayout(NSAttributedString(string: item.title, font: titleFont, textColor: item.kind == .destructive ? UIColor(0xff3b30) : UIColor(0x1195f2)), nil, 1, .end, CGSize(width: width - 20, height: CGFloat.greatestFiniteMagnitude), nil) + + let contentSize = CGSize(width: width, height: 44.0) + return (ListViewItemNodeLayout(contentSize: contentSize, insets: insets), { [weak self] in + if let strongSelf = self { + let _ = titleApply() + + let leftInset: CGFloat = 35.0 + + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 12.0), size: titleLayout.size) + + strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentSize.height - UIScreenPixel), size: CGSize(width: width - leftInset, height: UIScreenPixel)) + + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: width, height: 44.0 + UIScreenPixel + UIScreenPixel)) + } + }) + } + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + if highlighted { + self.highlightedBackgroundNode.alpha = 1.0 + if self.highlightedBackgroundNode.supernode == nil { + self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode) + } + } else { + if self.highlightedBackgroundNode.supernode != nil { + if animated { + self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in + if let strongSelf = self { + if completed { + strongSelf.highlightedBackgroundNode.removeFromSupernode() + } + } + }) + self.highlightedBackgroundNode.alpha = 0.0 + } else { + self.highlightedBackgroundNode.removeFromSupernode() + } + } + } + } +} diff --git a/TelegramUI/PeerInfoAvatarAndNameItem.swift b/TelegramUI/PeerInfoAvatarAndNameItem.swift new file mode 100644 index 0000000000..d0920dd47a --- /dev/null +++ b/TelegramUI/PeerInfoAvatarAndNameItem.swift @@ -0,0 +1,142 @@ +import Foundation +import Display +import AsyncDisplayKit +import Postbox +import TelegramCore +import SwiftSignalKit + +class PeerInfoAvatarAndNameItem: ListViewItem, PeerInfoItem { + let account: Account + let peer: Peer? + let cachedData: CachedPeerData? + let sectionId: PeerInfoItemSectionId + + init(account: Account, peer: Peer?, cachedData: CachedPeerData?, sectionId: PeerInfoItemSectionId) { + self.account = account + self.peer = peer + self.cachedData = cachedData + self.sectionId = sectionId + } + + func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> Void) -> Void) { + async { + let node = PeerInfoAvatarAndNameItemNode() + let (layout, apply) = node.asyncLayout()(self, width, peerInfoItemInsets(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + completion(node, { + apply() + }) + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { + if let node = node as? PeerInfoAvatarAndNameItemNode { + Queue.mainQueue().async { + let makeLayout = node.asyncLayout() + + async { + let (layout, apply) = makeLayout(self, width, peerInfoItemInsets(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + Queue.mainQueue().async { + completion(layout, { + apply() + }) + } + } + } + } + } + + var selectable: Bool = true + + func selected(listView: ListView){ + + } +} + +private let nameFont = Font.medium(19.0) +private let statusFont = Font.regular(15.0) + +class PeerInfoAvatarAndNameItemNode: ListViewItemNode { + let avatarNode: AvatarNode + + let nameNode: TextNode + let statusNode: TextNode + + init() { + self.avatarNode = AvatarNode(font: Font.regular(20.0)) + + self.nameNode = TextNode() + self.nameNode.isLayerBacked = true + self.nameNode.contentMode = .left + self.nameNode.contentsScale = UIScreen.main.scale + + self.statusNode = TextNode() + self.statusNode.isLayerBacked = true + self.statusNode.contentMode = .left + self.statusNode.contentsScale = UIScreen.main.scale + + super.init(layerBacked: false, dynamicBounce: false) + + self.addSubnode(self.avatarNode) + self.addSubnode(self.nameNode) + self.addSubnode(self.statusNode) + } + + func asyncLayout() -> (_ item: PeerInfoAvatarAndNameItem, _ width: CGFloat, _ insets: UIEdgeInsets) -> (ListViewItemNodeLayout, () -> Void) { + let layoutNameNode = TextNode.asyncLayout(self.nameNode) + let layoutStatusNode = TextNode.asyncLayout(self.statusNode) + + return { item, width, insets in + let (nameNodeLayout, nameNodeApply) = layoutNameNode(NSAttributedString(string: item.peer?.displayTitle ?? "", font: nameFont, textColor: UIColor.black), nil, 1, .end, CGSize(width: width - 20, height: CGFloat.greatestFiniteMagnitude), nil) + + let statusText: String + let statusColor: UIColor + if let user = item.peer as? TelegramUser { + statusText = "online" + statusColor = UIColor(0x1195f2) + } else if let channel = item.peer as? TelegramChannel { + if let cachedChannelData = item.cachedData as? CachedChannelData, let memberCount = cachedChannelData.participantsSummary.memberCount { + statusText = "\(memberCount) members" + statusColor = UIColor(0xb3b3b3) + } else { + switch channel.info { + case .broadcast: + statusText = "channel" + statusColor = UIColor(0xb3b3b3) + case .group: + statusText = "group" + statusColor = UIColor(0xb3b3b3) + } + } + } else if let group = item.peer as? TelegramGroup { + statusText = "\(group.participantCount) members" + statusColor = UIColor(0xb3b3b3) + } else { + statusText = "" + statusColor = UIColor.black + } + + let (statusNodeLayout, statusNodeApply) = layoutStatusNode(NSAttributedString(string: statusText, font: statusFont, textColor: statusColor), nil, 1, .end, CGSize(width: width - 20, height: CGFloat.greatestFiniteMagnitude), nil) + + return (ListViewItemNodeLayout(contentSize: CGSize(width: width, height: 96.0), insets: insets), { [weak self] in + if let strongSelf = self { + let _ = nameNodeApply() + let _ = statusNodeApply() + + if let peer = item.peer { + strongSelf.avatarNode.setPeer(account: item.account, peer: peer) + } + + strongSelf.avatarNode.frame = CGRect(origin: CGPoint(x: 15.0, y: 15.0), size: CGSize(width: 66.0, height: 66.0)) + strongSelf.nameNode.frame = CGRect(origin: CGPoint(x: 94.0, y: 25.0), size: nameNodeLayout.size) + + + strongSelf.statusNode.frame = CGRect(origin: CGPoint(x: 94.0, y: 25.0 + nameNodeLayout.size.height + 4.0), size: statusNodeLayout.size) + } + }) + } + } +} diff --git a/TelegramUI/PeerInfoDisclosureItem.swift b/TelegramUI/PeerInfoDisclosureItem.swift new file mode 100644 index 0000000000..3942a6d46f --- /dev/null +++ b/TelegramUI/PeerInfoDisclosureItem.swift @@ -0,0 +1,157 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit + +class PeerInfoDisclosureItem: ListViewItem, PeerInfoItem { + let title: String + let label: String + let sectionId: PeerInfoItemSectionId + let action: () -> Void + + init(title: String, label: String, sectionId: PeerInfoItemSectionId, action: @escaping () -> Void) { + self.title = title + self.label = label + self.sectionId = sectionId + self.action = action + } + + func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> Void) -> Void) { + async { + let node = PeerInfoDisclosureItemNode() + let (layout, apply) = node.asyncLayout()(self, width, peerInfoItemInsets(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + completion(node, { + apply() + }) + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { + if let node = node as? PeerInfoDisclosureItemNode { + Queue.mainQueue().async { + let makeLayout = node.asyncLayout() + + async { + let (layout, apply) = makeLayout(self, width, peerInfoItemInsets(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + Queue.mainQueue().async { + completion(layout, { + apply() + }) + } + } + } + } + } + + var selectable: Bool = true + + func selected(listView: ListView){ + listView.clearHighlightAnimated(true) + self.action() + } +} + +private let titleFont = Font.regular(17.0) +private let arrowImage = UIImage(bundleImageName: "Peer Info/DisclosureArrow")?.precomposed() + +class PeerInfoDisclosureItemNode: ListViewItemNode { + let titleNode: TextNode + let labelNode: TextNode + let arrowNode: ASImageNode + let separatorNode: ASDisplayNode + private let highlightedBackgroundNode: ASDisplayNode + + init() { + self.titleNode = TextNode() + self.titleNode.isLayerBacked = true + + self.labelNode = TextNode() + self.labelNode.isLayerBacked = true + + self.arrowNode = ASImageNode() + self.arrowNode.displayWithoutProcessing = true + self.arrowNode.displaysAsynchronously = false + self.arrowNode.isLayerBacked = true + self.arrowNode.image = arrowImage + + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.backgroundColor = UIColor(0xd9d9d9) + self.highlightedBackgroundNode.isLayerBacked = true + + self.separatorNode = ASDisplayNode() + self.separatorNode.isLayerBacked = true + self.separatorNode.displaysAsynchronously = false + self.separatorNode.backgroundColor = UIColor(0xc8c7cc) + + super.init(layerBacked: false, dynamicBounce: false) + + self.addSubnode(self.separatorNode) + self.addSubnode(self.titleNode) + self.addSubnode(self.labelNode) + self.addSubnode(self.arrowNode) + } + + func asyncLayout() -> (_ item: PeerInfoDisclosureItem, _ width: CGFloat, _ insets: UIEdgeInsets) -> (ListViewItemNodeLayout, () -> Void) { + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + let makeLabelLayout = TextNode.asyncLayout(self.labelNode) + + return { item, width, insets in + let sectionInset: CGFloat = 22.0 + let rightInset: CGFloat = 34.0 + + let (titleLayout, titleApply) = makeTitleLayout(NSAttributedString(string: item.title, font: titleFont, textColor: UIColor.black), nil, 1, .end, CGSize(width: width - 20, height: CGFloat.greatestFiniteMagnitude), nil) + let (labelLayout, labelApply) = makeLabelLayout(NSAttributedString(string: item.label, font: titleFont, textColor: UIColor(0x8e8e93)), nil, 1, .end, CGSize(width: width - 20, height: CGFloat.greatestFiniteMagnitude), nil) + + let contentSize = CGSize(width: width, height: 44.0) + return (ListViewItemNodeLayout(contentSize: contentSize, insets: insets), { [weak self] in + if let strongSelf = self { + let _ = titleApply() + let _ = labelApply() + + let leftInset: CGFloat = 35.0 + + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 12.0), size: titleLayout.size) + strongSelf.labelNode.frame = CGRect(origin: CGPoint(x: width - rightInset - labelLayout.size.width, y: 12.0), size: labelLayout.size) + + if let arrowImage = arrowImage { + strongSelf.arrowNode.frame = CGRect(origin: CGPoint(x: width - 15.0 - arrowImage.size.width, y: 18.0), size: arrowImage.size) + } + + strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentSize.height - UIScreenPixel), size: CGSize(width: width - leftInset, height: UIScreenPixel)) + + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: width, height: 44.0 + UIScreenPixel + UIScreenPixel)) + } + }) + } + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + if highlighted { + self.highlightedBackgroundNode.alpha = 1.0 + if self.highlightedBackgroundNode.supernode == nil { + self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode) + } + } else { + if self.highlightedBackgroundNode.supernode != nil { + if animated { + self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in + if let strongSelf = self { + if completed { + strongSelf.highlightedBackgroundNode.removeFromSupernode() + } + } + }) + self.highlightedBackgroundNode.alpha = 0.0 + } else { + self.highlightedBackgroundNode.removeFromSupernode() + } + } + } + } +} diff --git a/TelegramUI/PeerInfoItem.swift b/TelegramUI/PeerInfoItem.swift new file mode 100644 index 0000000000..ac2b083ab0 --- /dev/null +++ b/TelegramUI/PeerInfoItem.swift @@ -0,0 +1,18 @@ + +typealias PeerInfoItemSectionId = UInt32 + +protocol PeerInfoItem { + var sectionId: PeerInfoItemSectionId { get } +} + +func peerInfoItemInsets(item: PeerInfoItem, topItem: PeerInfoItem?, bottomItem: PeerInfoItem?) -> UIEdgeInsets { + var insets = UIEdgeInsets() + if let topItem = topItem, topItem.sectionId != item.sectionId { + insets.top += 22.0 + } + if bottomItem == nil { + insets.bottom += 22.0 + } + + return insets +} diff --git a/TelegramUI/PeerInfoTextWithLabelItem.swift b/TelegramUI/PeerInfoTextWithLabelItem.swift new file mode 100644 index 0000000000..7c1984bb47 --- /dev/null +++ b/TelegramUI/PeerInfoTextWithLabelItem.swift @@ -0,0 +1,117 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit + +final class PeerInfoTextWithLabelItem: ListViewItem, PeerInfoItem { + let label: String + let text: String + let multiline: Bool + let sectionId: PeerInfoItemSectionId + + init(label: String, text: String, multiline: Bool, sectionId: PeerInfoItemSectionId) { + self.label = label + self.text = text + self.multiline = multiline + self.sectionId = sectionId + } + + func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> Void) -> Void) { + async { + let node = PeerInfoTextWithLabelItemNode() + let (layout, apply) = node.asyncLayout()(self, width, peerInfoItemInsets(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + completion(node, { + apply() + }) + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { + if let node = node as? PeerInfoTextWithLabelItemNode { + Queue.mainQueue().async { + let makeLayout = node.asyncLayout() + + async { + let (layout, apply) = makeLayout(self, width, peerInfoItemInsets(item: self, topItem: previousItem as? PeerInfoItem, bottomItem: nextItem as? PeerInfoItem)) + Queue.mainQueue().async { + completion(layout, { + apply() + }) + } + } + } + } + } + + var selectable: Bool = true + + func selected(listView: ListView){ + + } +} + +private let labelFont = Font.regular(14.0) +private let textFont = Font.regular(17.0) + +class PeerInfoTextWithLabelItemNode: ListViewItemNode { + let labelNode: TextNode + let textNode: TextNode + let separatorNode: ASDisplayNode + + init() { + self.labelNode = TextNode() + self.labelNode.isLayerBacked = true + self.labelNode.contentMode = .left + self.labelNode.contentsScale = UIScreen.main.scale + + self.textNode = TextNode() + self.textNode.isLayerBacked = true + self.textNode.contentMode = .left + self.textNode.contentsScale = UIScreen.main.scale + + self.separatorNode = ASDisplayNode() + self.separatorNode.isLayerBacked = true + self.separatorNode.displaysAsynchronously = false + self.separatorNode.backgroundColor = UIColor(0xc8c7cc) + + super.init(layerBacked: false, dynamicBounce: false) + + self.addSubnode(self.separatorNode) + self.addSubnode(self.labelNode) + self.addSubnode(self.textNode) + } + + func asyncLayout() -> (_ item: PeerInfoTextWithLabelItem, _ width: CGFloat, _ insets: UIEdgeInsets) -> (ListViewItemNodeLayout, () -> Void) { + let makeLabelLayout = TextNode.asyncLayout(self.labelNode) + let makeTextLayout = TextNode.asyncLayout(self.textNode) + + return { item, width, insets in + let leftInset: CGFloat = 35.0 + + let (labelLayout, labelApply) = makeLabelLayout(NSAttributedString(string: item.label, font: labelFont, textColor: UIColor(0x1195f2)), nil, 1, .end, CGSize(width: width - leftInset - 8.0, height: CGFloat.greatestFiniteMagnitude), nil) + let (textLayout, textApply) = makeTextLayout(NSAttributedString(string: item.text, font: textFont, textColor: UIColor.black), nil, item.multiline ? 0 : 1, .end, CGSize(width: width - leftInset - 8.0, height: CGFloat.greatestFiniteMagnitude), nil) + let contentSize = CGSize(width: width, height: textLayout.size.height + 39.0) + return (ListViewItemNodeLayout(contentSize: contentSize, insets: insets), { [weak self] in + if let strongSelf = self { + let _ = labelApply() + let _ = textApply() + + strongSelf.labelNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 11.0), size: labelLayout.size) + strongSelf.textNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 31.0), size: textLayout.size) + + strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentSize.height - UIScreenPixel), size: CGSize(width: width - leftInset, height: UIScreenPixel)) + } + }) + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double) { + self.labelNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) + self.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) + self.separatorNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) + } +} diff --git a/TelegramUI/PeerMediaCollectionController.swift b/TelegramUI/PeerMediaCollectionController.swift new file mode 100644 index 0000000000..74c94f47fd --- /dev/null +++ b/TelegramUI/PeerMediaCollectionController.swift @@ -0,0 +1,303 @@ +import Foundation +import UIKit +import Postbox +import SwiftSignalKit +import Display +import AsyncDisplayKit +import TelegramCore + +public class PeerMediaCollectionController: ViewController { + private var containerLayout = ContainerViewLayout() + + private let account: Account + private let peerId: PeerId + private let messageId: MessageId? + + private let peerDisposable = MetaDisposable() + private let navigationActionDisposable = MetaDisposable() + + private let messageIndexDisposable = MetaDisposable() + + private let _peerReady = Promise() + private var didSetPeerReady = false + private let peer = Promise(nil) + + private var interfaceState = PeerMediaCollectionInterfaceState() + + private var rightNavigationButton: PeerMediaCollectionNavigationButton? + + private let galleryHiddenMesageAndMediaDisposable = MetaDisposable() + + private var titleView: PeerMediaCollectionTitleView? + private var controllerInteraction: ChatControllerInteraction? + private var interfaceInteraction: ChatPanelInterfaceInteraction? + + public init(account: Account, peerId: PeerId, messageId: MessageId? = nil) { + self.account = account + self.peerId = peerId + self.messageId = messageId + + super.init() + + self.titleView = PeerMediaCollectionTitleView(toggle: { [weak self] in + self?.updateInterfaceState { $0.withToggledSelectingMode() } + }) + + self.navigationItem.titleView = self.titleView + + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + + self.ready.set(.never()) + + self.scrollToTop = { [weak self] in + if let strongSelf = self, strongSelf.isNodeLoaded { + //strongSelf.chatDisplayNode.historyNode.scrollToStartOfHistory() + } + } + + let controllerInteraction = ChatControllerInteraction(openMessage: { [weak self] id in + if let strongSelf = self, strongSelf.isNodeLoaded { + var galleryMedia: Media? + if let message = strongSelf.mediaCollectionDisplayNode.historyNode.messageInCurrentHistoryView(id) { + for media in message.media { + if let file = media as? TelegramMediaFile { + galleryMedia = file + } else if let image = media as? TelegramMediaImage { + galleryMedia = image + } else if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content { + if let file = content.file { + galleryMedia = file + } else if let image = content.image { + galleryMedia = image + } + } + } + } + + if let galleryMedia = galleryMedia { + if let file = galleryMedia as? TelegramMediaFile, file.mimeType == "audio/mpeg" { + //debugPlayMedia(account: strongSelf.account, file: file) + } else { + let gallery = GalleryController(account: strongSelf.account, messageId: id) + + strongSelf.galleryHiddenMesageAndMediaDisposable.set(gallery.hiddenMedia.start(next: { [weak strongSelf] messageIdAndMedia in + if let strongSelf = strongSelf { + if let messageIdAndMedia = messageIdAndMedia { + strongSelf.controllerInteraction?.hiddenMedia = [messageIdAndMedia.0: [messageIdAndMedia.1]] + } else { + strongSelf.controllerInteraction?.hiddenMedia = [:] + } + strongSelf.mediaCollectionDisplayNode.historyNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ChatMessageItemView { + itemNode.updateHiddenMedia() + } else if let itemNode = itemNode as? ListMessageNode { + itemNode.updateHiddenMedia() + } else if let itemNode = itemNode as? GridMessageItemNode { + itemNode.updateHiddenMedia() + } + } + } + })) + + strongSelf.present(gallery, in: .window, with: GalleryControllerPresentationArguments(transitionArguments: { [weak self] messageId, media in + if let strongSelf = self { + var transitionNode: ASDisplayNode? + strongSelf.mediaCollectionDisplayNode.historyNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ChatMessageItemView { + if let result = itemNode.transitionNode(id: messageId, media: media) { + transitionNode = result + } + } else if let itemNode = itemNode as? ListMessageNode { + if let result = itemNode.transitionNode(id: messageId, media: media) { + transitionNode = result + } + } else if let itemNode = itemNode as? GridMessageItemNode { + if let result = itemNode.transitionNode(id: messageId, media: media) { + transitionNode = result + } + } + } + if let transitionNode = transitionNode { + return GalleryTransitionArguments(transitionNode: transitionNode, transitionContainerNode: strongSelf.mediaCollectionDisplayNode, transitionBackgroundNode: strongSelf.mediaCollectionDisplayNode.historyNode as! ASDisplayNode) + } + } + return nil + })) + } + } + } + }, openPeer: { [weak self] id, navigation in + if let strongSelf = self { + (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: id, messageId: nil)) + } + }, openMessageContextMenu: { [weak self] id, node, frame in + if let strongSelf = self, strongSelf.isNodeLoaded { + if let message = strongSelf.mediaCollectionDisplayNode.historyNode.messageInCurrentHistoryView(id) { + /*if let contextMenuController = contextMenuForChatPresentationIntefaceState(strongSelf.presentationInterfaceState, account: strongSelf.account, message: message, interfaceInteraction: strongSelf.interfaceInteraction) { + strongSelf.present(contextMenuController, in: .window, with: ContextMenuControllerPresentationArguments(sourceNodeAndRect: { [weak strongSelf, weak node] in + if let node = node { + return (node, frame) + } else { + return nil + } + })) + }*/ + } + } + }, navigateToMessage: { [weak self] fromId, id in + if let strongSelf = self, strongSelf.isNodeLoaded { + if id.peerId == strongSelf.peerId { + var fromIndex: MessageIndex? + + if let message = strongSelf.mediaCollectionDisplayNode.historyNode.messageInCurrentHistoryView(fromId) { + fromIndex = MessageIndex(message) + } + + /*if let fromIndex = fromIndex { + if let message = strongSelf.mediaCollectionDisplayNode.historyNode.messageInCurrentHistoryView(id) { + strongSelf.mediaCollectionDisplayNode.historyNode.scrollToMessage(from: fromIndex, to: MessageIndex(message)) + } else { + strongSelf.messageIndexDisposable.set((strongSelf.account.postbox.messageIndexAtId(id) |> deliverOnMainQueue).start(next: { [weak strongSelf] index in + if let strongSelf = strongSelf, let index = index { + strongSelf.mediaCollectionDisplayNode.historyNode.scrollToMessage(from: fromIndex, to: index) + } + })) + } + }*/ + } else { + (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: id.peerId, messageId: id)) + } + } + }, clickThroughMessage: { [weak self] in + self?.view.endEditing(true) + }, toggleMessageSelection: { [weak self] id in + if let strongSelf = self, strongSelf.isNodeLoaded { + if let message = strongSelf.mediaCollectionDisplayNode.historyNode.messageInCurrentHistoryView(id) { + strongSelf.updateInterfaceState(animated: true, { $0.withToggledSelectedMessage(id) }) + } + } + }) + + self.controllerInteraction = controllerInteraction + + self.interfaceInteraction = ChatPanelInterfaceInteraction(setupReplyMessage: { _ in }, beginMessageSelection: { _ in }, deleteSelectedMessages: { + + }, forwardSelectedMessages: { + + }, updateTextInputState: { _ in }) + + self.updateInterfaceState(animated: false, { return $0 }) + + self.peer.set(account.postbox.peerView(id: peerId) |> map { $0.peers[$0.peerId] }) + + peerDisposable.set((self.peer.get() + |> deliverOnMainQueue).start(next: { [weak self] peer in + if let strongSelf = self { + strongSelf.updateInterfaceState(animated: false, { return $0.withUpdatedPeer(peer) }) + if !strongSelf.didSetPeerReady { + strongSelf.didSetPeerReady = true + strongSelf._peerReady.set(.single(true)) + } + } + })) + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.messageIndexDisposable.dispose() + self.navigationActionDisposable.dispose() + self.galleryHiddenMesageAndMediaDisposable.dispose() + } + + var mediaCollectionDisplayNode: PeerMediaCollectionControllerNode { + get { + return super.displayNode as! PeerMediaCollectionControllerNode + } + } + + override public func loadDisplayNode() { + self.displayNode = PeerMediaCollectionControllerNode(account: self.account, peerId: self.peerId, messageId: self.messageId, controllerInteraction: self.controllerInteraction!) + + self.ready.set(combineLatest(self.mediaCollectionDisplayNode.historyNode.historyReady.get(), self._peerReady.get()) |> map { $0 && $1 }) + + self.mediaCollectionDisplayNode.requestLayout = { [weak self] transition in + self?.requestLayout(transition: transition) + } + + self.mediaCollectionDisplayNode.requestUpdateMediaCollectionInterfaceState = { [weak self] animated, f in + self?.updateInterfaceState(animated: animated, f) + } + + self.displayNodeDidLoad() + } + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.mediaCollectionDisplayNode.historyNode.preloadPages = true + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.containerLayout = layout + + self.mediaCollectionDisplayNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationBar.frame.maxY, transition: transition, listViewTransaction: { updateSizeAndInsets in + self.mediaCollectionDisplayNode.historyNode.updateLayout(transition: transition, updateSizeAndInsets: updateSizeAndInsets) + }) + } + + func updateInterfaceState(animated: Bool = true, _ f: (PeerMediaCollectionInterfaceState) -> PeerMediaCollectionInterfaceState) { + let updatedInterfaceState = f(self.interfaceState) + + if self.isNodeLoaded { + self.mediaCollectionDisplayNode.updateMediaCollectionInterfaceState(updatedInterfaceState, animated: animated) + self.titleView?.updateMediaCollectionInterfaceState(updatedInterfaceState, animated: animated) + } + self.interfaceState = updatedInterfaceState + + if let button = rightNavigationButtonForPeerMediaCollectionInterfaceState(updatedInterfaceState, currentButton: self.rightNavigationButton, target: self, selector: #selector(self.rightNavigationButtonAction)) { + self.navigationItem.setRightBarButton(button.buttonItem, animated: true) + self.rightNavigationButton = button + } else if let _ = self.rightNavigationButton { + self.navigationItem.setRightBarButton(nil, animated: true) + self.rightNavigationButton = nil + } + + if let controllerInteraction = self.controllerInteraction { + if updatedInterfaceState.selectionState != controllerInteraction.selectionState { + let animated = animated || controllerInteraction.selectionState == nil || updatedInterfaceState.selectionState == nil + controllerInteraction.selectionState = updatedInterfaceState.selectionState + self.mediaCollectionDisplayNode.historyNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ChatMessageItemView { + itemNode.updateSelectionState(animated: animated) + } else if let itemNode = itemNode as? GridMessageItemNode { + itemNode.updateSelectionState(animated: animated) + } + } + } + } + } + + @objc func rightNavigationButtonAction() { + if let button = self.rightNavigationButton { + self.navigationButtonAction(button.action) + } + } + + private func navigationButtonAction(_ action: PeerMediaCollectionNavigationButtonAction) { + switch action { + case .cancelMessageSelection: + self.updateInterfaceState(animated: true, { $0.withoutSelectionState() }) + case .beginMessageSelection: + self.updateInterfaceState(animated: true, { $0.withSelectionState() }) + } + } +} diff --git a/TelegramUI/PeerMediaCollectionControllerNode.swift b/TelegramUI/PeerMediaCollectionControllerNode.swift new file mode 100644 index 0000000000..0265f41ea2 --- /dev/null +++ b/TelegramUI/PeerMediaCollectionControllerNode.swift @@ -0,0 +1,217 @@ +import Foundation +import AsyncDisplayKit +import Postbox +import SwiftSignalKit +import Display +import TelegramCore + +private func historyNodeImplForMode(_ mode: PeerMediaCollectionMode, account: Account, peerId: PeerId, messageId: MessageId?, controllerInteraction: ChatControllerInteraction) -> ASDisplayNode { + switch mode { + case .photoOrVideo: + return ChatHistoryGridNode(account: account, peerId: peerId, messageId: messageId, tagMask: .PhotoOrVideo, controllerInteraction: controllerInteraction) + case .file: + let node = ChatHistoryListNode(account: account, peerId: peerId, tagMask: .File, messageId: messageId, controllerInteraction: controllerInteraction, mode: .list) + node.preloadPages = true + return node + case .music: + let node = ChatHistoryListNode(account: account, peerId: peerId, tagMask: .Music, messageId: messageId, controllerInteraction: controllerInteraction, mode: .list) + node.preloadPages = true + return node + case .webpage: + let node = ChatHistoryListNode(account: account, peerId: peerId, tagMask: .WebPage, messageId: messageId, controllerInteraction: controllerInteraction, mode: .list) + node.preloadPages = true + return node + } +} + +class PeerMediaCollectionControllerNode: ASDisplayNode { + private let account: Account + private let peerId: PeerId + private let controllerInteraction: ChatControllerInteraction + + private var historyNodeImpl: ASDisplayNode + var historyNode: ChatHistoryNode { + return self.historyNodeImpl as! ChatHistoryNode + } + + private let candidateHistoryNodeReadyDisposable = MetaDisposable() + private var candidateHistoryNode: (ASDisplayNode, PeerMediaCollectionMode)? + + private var containerLayout: (ContainerViewLayout, CGFloat)? + + var requestLayout: (ContainedViewLayoutTransition) -> Void = { _ in } + var requestUpdateMediaCollectionInterfaceState: (Bool, (PeerMediaCollectionInterfaceState) -> PeerMediaCollectionInterfaceState) -> Void = { _ in } + + private var mediaCollectionInterfaceState = PeerMediaCollectionInterfaceState() + + private var modeSelectionNode: PeerMediaCollectionModeSelectionNode? + private var selectionPanel: ChatMessageSelectionInputPanelNode? + + init(account: Account, peerId: PeerId, messageId: MessageId?, controllerInteraction: ChatControllerInteraction) { + self.account = account + self.peerId = peerId + self.controllerInteraction = controllerInteraction + + self.historyNodeImpl = historyNodeImplForMode(self.mediaCollectionInterfaceState.mode, account: account, peerId: peerId, messageId: messageId, controllerInteraction: controllerInteraction) + + super.init(viewBlock: { + return UITracingLayerView() + }, didLoad: nil) + + self.backgroundColor = UIColor.white + + self.addSubnode(self.historyNodeImpl) + } + + deinit { + self.candidateHistoryNodeReadyDisposable.dispose() + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition, listViewTransaction: (ListViewUpdateSizeAndInsets) -> Void) { + self.containerLayout = (layout, navigationBarHeight) + + var insets = layout.insets(options: [.input]) + insets.top += navigationBarHeight + + if let selectionState = self.mediaCollectionInterfaceState.selectionState { + if let selectionPanel = self.selectionPanel { + selectionPanel.peer = self.mediaCollectionInterfaceState.peer + selectionPanel.selectedMessageCount = selectionState.selectedIds.count + let panelSize = selectionPanel.measure(layout.size) + transition.updateFrame(node: selectionPanel, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - insets.bottom - panelSize.height), size: panelSize)) + } else { + let selectionPanel = ChatMessageSelectionInputPanelNode() + selectionPanel.peer = self.mediaCollectionInterfaceState.peer + selectionPanel.selectedMessageCount = selectionState.selectedIds.count + selectionPanel.backgroundColor = UIColor(0xfafafa) + let panelSize = selectionPanel.measure(layout.size) + selectionPanel.frame = CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - insets.bottom), size: panelSize) + self.selectionPanel = selectionPanel + self.addSubnode(selectionPanel) + transition.updateFrame(node: selectionPanel, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - insets.bottom - panelSize.height), size: panelSize)) + } + } else if let selectionPanel = self.selectionPanel { + self.selectionPanel = nil + transition.updateFrame(node: selectionPanel, frame: selectionPanel.frame.offsetBy(dx: 0.0, dy: selectionPanel.bounds.size.height), completion: { [weak selectionPanel] _ in + selectionPanel?.removeFromSupernode() + }) + } + + var duration: Double = 0.0 + var curve: UInt = 0 + switch transition { + case .immediate: + break + case let .animated(animationDuration, animationCurve): + duration = animationDuration + switch animationCurve { + case .easeInOut: + break + case .spring: + curve = 7 + } + } + + let previousBounds = self.historyNodeImpl.bounds + self.historyNodeImpl.bounds = CGRect(x: previousBounds.origin.x, y: previousBounds.origin.y, width: layout.size.width, height: layout.size.height) + self.historyNodeImpl.position = CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0) + + let listViewCurve: ListViewAnimationCurve + if curve == 7 { + listViewCurve = .Spring(duration: duration) + } else { + listViewCurve = .Default + } + + var additionalBottomInset: CGFloat = 0.0 + if let selectionPanel = self.selectionPanel { + additionalBottomInset = selectionPanel.bounds.size.height + } + + listViewTransaction(ListViewUpdateSizeAndInsets(size: layout.size, insets: UIEdgeInsets(top: insets.top, left: + insets.right, bottom: insets.bottom + additionalBottomInset, right: insets.left), duration: duration, curve: listViewCurve)) + + if let (candidateHistoryNode, _) = self.candidateHistoryNode { + let previousBounds = candidateHistoryNode.bounds + candidateHistoryNode.bounds = CGRect(x: previousBounds.origin.x, y: previousBounds.origin.y, width: layout.size.width, height: layout.size.height) + candidateHistoryNode.position = CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0) + + (candidateHistoryNode as! ChatHistoryNode).updateLayout(transition: transition, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: UIEdgeInsets(top: insets.top, left: + insets.right, bottom: insets.bottom + additionalBottomInset, right: insets.left), duration: duration, curve: listViewCurve)) + } + + if self.mediaCollectionInterfaceState.selectingMode { + if let modeSelectionNode = self.modeSelectionNode { + modeSelectionNode.frame = CGRect(origin: CGPoint(), size: layout.size) + modeSelectionNode.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) + modeSelectionNode.mediaCollectionInterfaceState = self.mediaCollectionInterfaceState + } else { + let modeSelectionNode = PeerMediaCollectionModeSelectionNode() + modeSelectionNode.selectedMode = { [weak self] mode in + if let requestUpdateMediaCollectionInterfaceState = self?.requestUpdateMediaCollectionInterfaceState { + requestUpdateMediaCollectionInterfaceState(true, { $0.withToggledSelectingMode().withMode(mode) }) + } + } + modeSelectionNode.dismiss = { [weak self] in + if let requestUpdateMediaCollectionInterfaceState = self?.requestUpdateMediaCollectionInterfaceState { + requestUpdateMediaCollectionInterfaceState(true, { $0.withToggledSelectingMode() }) + } + } + modeSelectionNode.frame = CGRect(origin: CGPoint(), size: layout.size) + modeSelectionNode.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) + modeSelectionNode.mediaCollectionInterfaceState = self.mediaCollectionInterfaceState + self.insertSubnode(modeSelectionNode, aboveSubnode: self.historyNodeImpl) + modeSelectionNode.animateIn() + self.modeSelectionNode = modeSelectionNode + } + } else if let modeSelectionNode = self.modeSelectionNode { + self.modeSelectionNode = nil + modeSelectionNode.animateOut { [weak modeSelectionNode] in + modeSelectionNode?.removeFromSupernode() + } + } + } + + func updateMediaCollectionInterfaceState(_ mediaCollectionInterfaceState: PeerMediaCollectionInterfaceState, animated: Bool) { + if self.mediaCollectionInterfaceState != mediaCollectionInterfaceState { + if self.mediaCollectionInterfaceState.mode != mediaCollectionInterfaceState.mode { + if let containerLayout = self.containerLayout, self.candidateHistoryNode == nil || self.candidateHistoryNode!.1 != mediaCollectionInterfaceState.mode { + let node = historyNodeImplForMode(mediaCollectionInterfaceState.mode, account: self.account, peerId: self.peerId, messageId: nil, controllerInteraction: self.controllerInteraction) + self.candidateHistoryNode = (node, mediaCollectionInterfaceState.mode) + + var insets = containerLayout.0.insets(options: [.input]) + insets.top += containerLayout.1 + + let previousBounds = node.bounds + node.bounds = CGRect(x: previousBounds.origin.x, y: previousBounds.origin.y, width: containerLayout.0.size.width, height: containerLayout.0.size.height) + node.position = CGPoint(x: containerLayout.0.size.width / 2.0, y: containerLayout.0.size.height / 2.0) + + var additionalBottomInset: CGFloat = 0.0 + if let selectionPanel = self.selectionPanel { + additionalBottomInset = selectionPanel.bounds.size.height + } + + (node as! ChatHistoryNode).updateLayout(transition: .immediate, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: containerLayout.0.size, insets: UIEdgeInsets(top: insets.top, left: insets.right, bottom: insets.bottom + additionalBottomInset, right: insets.left), duration: 0.0, curve: .Default)) + + self.candidateHistoryNodeReadyDisposable.set(((node as! ChatHistoryNode).historyReady.get() + |> deliverOnMainQueue).start(next: { [weak self, weak node] _ in + if let strongSelf = self, let strongNode = node, strongNode == strongSelf.candidateHistoryNode?.0 { + strongSelf.candidateHistoryNode = nil + strongSelf.insertSubnode(strongNode, aboveSubnode: strongSelf.historyNodeImpl) + strongSelf.historyNodeImpl.removeFromSupernode() + strongSelf.historyNodeImpl = strongNode + } + })) + } + } + + self.mediaCollectionInterfaceState = mediaCollectionInterfaceState + + if let modeSelectionNode = self.modeSelectionNode { + modeSelectionNode.mediaCollectionInterfaceState = mediaCollectionInterfaceState + } + + self.requestLayout(animated ? .animated(duration: 0.4, curve: .spring) : .immediate) + } + } +} diff --git a/TelegramUI/PeerMediaCollectionInterfaceState.swift b/TelegramUI/PeerMediaCollectionInterfaceState.swift new file mode 100644 index 0000000000..a8cd06bf3d --- /dev/null +++ b/TelegramUI/PeerMediaCollectionInterfaceState.swift @@ -0,0 +1,109 @@ +import Foundation +import Postbox + +enum PeerMediaCollectionMode { + case photoOrVideo + case file + case music + case webpage +} + +func titleForPeerMediaCollectionMode(_ mode: PeerMediaCollectionMode) -> String { + switch mode { + case .photoOrVideo: + return "Shared Media" + case .file: + return "Shared Files" + case .music: + return "Shared Music" + case .webpage: + return "Shared Links" + } +} + +struct PeerMediaCollectionInterfaceState: Equatable { + let peer: Peer? + let selectionState: ChatInterfaceSelectionState? + let mode: PeerMediaCollectionMode + let selectingMode: Bool + + init() { + self.peer = nil + self.selectionState = nil + self.mode = .photoOrVideo + self.selectingMode = false + } + + init(peer: Peer?, selectionState: ChatInterfaceSelectionState?, mode: PeerMediaCollectionMode, selectingMode: Bool) { + self.peer = peer + self.selectionState = selectionState + self.mode = mode + self.selectingMode = selectingMode + } + + static func ==(lhs: PeerMediaCollectionInterfaceState, rhs: PeerMediaCollectionInterfaceState) -> Bool { + if let peer = lhs.peer { + if rhs.peer == nil || !peer.isEqual(rhs.peer!) { + return false + } + } else if let _ = rhs.peer { + return false + } + + if lhs.selectionState != rhs.selectionState { + return false + } + + if lhs.mode != rhs.mode { + return false + } + + if lhs.selectingMode != rhs.selectingMode { + return false + } + + return true + } + + func withUpdatedSelectedMessage(_ messageId: MessageId) -> PeerMediaCollectionInterfaceState { + var selectedIds = Set() + if let selectionState = self.selectionState { + selectedIds.formUnion(selectionState.selectedIds) + } + selectedIds.insert(messageId) + return PeerMediaCollectionInterfaceState(peer: self.peer, selectionState: ChatInterfaceSelectionState(selectedIds: selectedIds), mode: self.mode, selectingMode: self.selectingMode) + } + + func withToggledSelectedMessage(_ messageId: MessageId) -> PeerMediaCollectionInterfaceState { + var selectedIds = Set() + if let selectionState = self.selectionState { + selectedIds.formUnion(selectionState.selectedIds) + } + if selectedIds.contains(messageId) { + let _ = selectedIds.remove(messageId) + } else { + selectedIds.insert(messageId) + } + return PeerMediaCollectionInterfaceState(peer: self.peer, selectionState: ChatInterfaceSelectionState(selectedIds: selectedIds), mode: self.mode, selectingMode: self.selectingMode) + } + + func withSelectionState() -> PeerMediaCollectionInterfaceState { + return PeerMediaCollectionInterfaceState(peer: self.peer, selectionState: self.selectionState ?? ChatInterfaceSelectionState(selectedIds: Set()), mode: self.mode, selectingMode: self.selectingMode) + } + + func withoutSelectionState() -> PeerMediaCollectionInterfaceState { + return PeerMediaCollectionInterfaceState(peer: self.peer, selectionState: nil, mode: self.mode, selectingMode: self.selectingMode) + } + + func withUpdatedPeer(_ peer: Peer?) -> PeerMediaCollectionInterfaceState { + return PeerMediaCollectionInterfaceState(peer: peer, selectionState: self.selectionState, mode: self.mode, selectingMode: self.selectingMode) + } + + func withToggledSelectingMode() -> PeerMediaCollectionInterfaceState { + return PeerMediaCollectionInterfaceState(peer: self.peer, selectionState: self.selectionState, mode: self.mode, selectingMode: !self.selectingMode) + } + + func withMode(_ mode: PeerMediaCollectionMode) -> PeerMediaCollectionInterfaceState { + return PeerMediaCollectionInterfaceState(peer: self.peer, selectionState: self.selectionState, mode: mode, selectingMode: self.selectingMode) + } +} diff --git a/TelegramUI/PeerMediaCollectionInterfaceStateButtons.swift b/TelegramUI/PeerMediaCollectionInterfaceStateButtons.swift new file mode 100644 index 0000000000..0d8142b5bf --- /dev/null +++ b/TelegramUI/PeerMediaCollectionInterfaceStateButtons.swift @@ -0,0 +1,31 @@ +import Foundation + +enum PeerMediaCollectionNavigationButtonAction { + case beginMessageSelection + case cancelMessageSelection +} + +struct PeerMediaCollectionNavigationButton: Equatable { + let action: PeerMediaCollectionNavigationButtonAction + let buttonItem: UIBarButtonItem + + static func ==(lhs: PeerMediaCollectionNavigationButton, rhs: PeerMediaCollectionNavigationButton) -> Bool { + return lhs.action == rhs.action + } +} + +func rightNavigationButtonForPeerMediaCollectionInterfaceState(_ interfaceState: PeerMediaCollectionInterfaceState, currentButton: PeerMediaCollectionNavigationButton?, target: Any?, selector: Selector?) -> PeerMediaCollectionNavigationButton? { + if let _ = interfaceState.selectionState { + if let currentButton = currentButton, currentButton.action == .cancelMessageSelection { + return currentButton + } else { + return PeerMediaCollectionNavigationButton(action: .cancelMessageSelection, buttonItem: UIBarButtonItem(title: "Cancel", style: .plain, target: target, action: selector)) + } + } else { + if let currentButton = currentButton, currentButton.action == .beginMessageSelection { + return currentButton + } else { + return PeerMediaCollectionNavigationButton(action: .beginMessageSelection, buttonItem: UIBarButtonItem(title: "Select", style: .plain, target: target, action: selector)) + } + } +} diff --git a/TelegramUI/PeerMediaCollectionModeSelectionNode.swift b/TelegramUI/PeerMediaCollectionModeSelectionNode.swift new file mode 100644 index 0000000000..417b651170 --- /dev/null +++ b/TelegramUI/PeerMediaCollectionModeSelectionNode.swift @@ -0,0 +1,188 @@ +import Foundation +import AsyncDisplayKit +import Display + +private let checkmarkImage = generateTintedImage(image: UIImage(bundleImageName: "List Menu/Checkmark")?.precomposed(), color: UIColor(0x1195f2)) + +private final class PeerMediaCollectionModeSelectionCaseNode: ASDisplayNode { + fileprivate let mode: PeerMediaCollectionMode + private let selected: () -> Void + + private let button: HighlightTrackingButton + private let selectionBackgroundNode: ASDisplayNode + private let separatorNode: ASDisplayNode + private let titleNode: ASTextNode + private let checkmarkView: UIImageView + + var isSelected = false { + didSet { + if self.isSelected != oldValue { + self.titleNode.attributedText = NSAttributedString(string: titleForPeerMediaCollectionMode(self.mode), font: Font.regular(17.0), textColor: isSelected ? UIColor(0x1195f2) : UIColor.black) + self.checkmarkView.isHidden = !self.isSelected + } + } + } + + init(mode: PeerMediaCollectionMode, selected: @escaping () -> Void) { + self.mode = mode + self.selected = selected + + self.button = HighlightTrackingButton() + + self.selectionBackgroundNode = ASDisplayNode() + self.selectionBackgroundNode.backgroundColor = UIColor(0xd9d9d9) + + self.separatorNode = ASDisplayNode() + self.separatorNode.backgroundColor = UIColor(0xc8c7cc) + + self.titleNode = ASTextNode() + self.titleNode.displaysAsynchronously = false + self.titleNode.maximumNumberOfLines = 1 + self.titleNode.truncationMode = .byTruncatingTail + self.titleNode.isOpaque = false + + self.checkmarkView = UIImageView(image: checkmarkImage) + + super.init() + + self.addSubnode(self.separatorNode) + + self.selectionBackgroundNode.alpha = 0.0 + self.addSubnode(self.selectionBackgroundNode) + + self.titleNode.attributedText = NSAttributedString(string: titleForPeerMediaCollectionMode(mode), font: Font.regular(17.0), textColor: UIColor.black) + self.addSubnode(self.titleNode) + + self.checkmarkView.isHidden = true + self.view.addSubview(self.checkmarkView) + + self.button.highligthedChanged = { [weak self] highlighted in + if let strongSelf = self { + if highlighted { + strongSelf.selectionBackgroundNode.layer.removeAnimation(forKey: "opacity") + strongSelf.selectionBackgroundNode.alpha = 1.0 + } else { + strongSelf.selectionBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3) + strongSelf.selectionBackgroundNode.layer.opacity = 0.0 + } + } + } + self.button.addTarget(self, action: #selector(self.buttonPressed), for: [.touchUpInside]) + self.view.addSubview(self.button) + } + + func updateFrames(size: CGSize, transition: ContainedViewLayoutTransition) { + transition.updateFrame(layer: self.button.layer, frame: CGRect(origin: CGPoint(), size: size)) + + let leftInset: CGFloat = 15.0 + + transition.updateFrame(node: self.selectionBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: size.width, height: size.height + UIScreenPixel))) + transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: leftInset, y: -UIScreenPixel), size: CGSize(width: size.width - leftInset, height: UIScreenPixel))) + + let titleSize = self.titleNode.measure(CGSize(width: size.width - leftInset - 44.0, height: size.height)) + transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: leftInset, y: floor((size.height - titleSize.height) / 2.0)), size: titleSize)) + + let checkmarkSize = self.checkmarkView.bounds.size + transition.updateFrame(layer: self.checkmarkView.layer, frame: CGRect(origin: CGPoint(x: size.width - checkmarkSize.width - 14.0, y: floor((size.height - checkmarkSize.height) / 2.0)), size: checkmarkSize)) + } + + @objc func buttonPressed() { + self.selected() + } +} + +final class PeerMediaCollectionModeSelectionNode: ASDisplayNode { + private let dimNode: ASDisplayNode + private let backgroundNode: ASDisplayNode + + private var caseNodes: [PeerMediaCollectionModeSelectionCaseNode] = [] + + var selectedMode: ((PeerMediaCollectionMode) -> Void)? + var dismiss: (() -> Void)? + + var mediaCollectionInterfaceState = PeerMediaCollectionInterfaceState() { + didSet { + for caseNode in self.caseNodes { + caseNode.isSelected = self.mediaCollectionInterfaceState.mode == caseNode.mode + } + } + } + + override init() { + self.dimNode = ASDisplayNode() + self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.4) + + self.backgroundNode = ASDisplayNode() + self.backgroundNode.backgroundColor = UIColor.white + + super.init() + + let modes: [PeerMediaCollectionMode] = [.photoOrVideo, .file, .webpage, .music] + let selected: (PeerMediaCollectionMode) -> Void = { [weak self] mode in + if let selectedMode = self?.selectedMode { + selectedMode(mode) + } + } + self.caseNodes = modes.map { mode in + return PeerMediaCollectionModeSelectionCaseNode(mode: mode, selected: { + selected(mode) + }) + } + + self.addSubnode(self.dimNode) + self.addSubnode(self.backgroundNode) + + for caseNode in self.caseNodes { + self.addSubnode(caseNode) + } + + self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimNodeTap(_:)))) + } + + func animateIn() { + self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + + let backgrounNodePosition = self.backgroundNode.layer.position + self.backgroundNode.layer.animatePosition(from: CGPoint(x: backgrounNodePosition.x, y: backgrounNodePosition.y - self.backgroundNode.bounds.size.height), to: backgrounNodePosition, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + + for caseNode in self.caseNodes { + let caseNodePosition = caseNode.layer.position + caseNode.layer.animatePosition(from: CGPoint(x: caseNodePosition.x, y: caseNodePosition.y - self.backgroundNode.bounds.size.height), to: caseNodePosition, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + } + } + + func animateOut(completion: @escaping () -> Void) { + self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + + let backgrounNodePosition = self.backgroundNode.layer.position + self.backgroundNode.layer.animatePosition(from: backgrounNodePosition, to: CGPoint(x: backgrounNodePosition.x, y: backgrounNodePosition.y - self.backgroundNode.bounds.size.height), duration: 0.2, removeOnCompletion: false, completion: { _ in + completion() + }) + + for caseNode in self.caseNodes { + let caseNodePosition = caseNode.layer.position + caseNode.layer.animatePosition(from: caseNodePosition, to: CGPoint(x: caseNodePosition.x, y: caseNodePosition.y - self.backgroundNode.bounds.size.height), duration: 0.2, removeOnCompletion: false) + } + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(x: 0.0, y: navigationBarHeight), size: CGSize(width: layout.size.width, height: layout.size.height - navigationBarHeight))) + + var nextCaseNodeOrigin = CGPoint(x: 0.0, y: navigationBarHeight) + for caseNode in self.caseNodes { + transition.updateFrame(node: caseNode, frame: CGRect(origin: nextCaseNodeOrigin, size: CGSize(width: layout.size.width, height: 44.0))) + caseNode.updateFrames(size: CGSize(width: layout.size.width, height: 44.0), transition: transition) + nextCaseNodeOrigin.y += 44.0 + } + + transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: navigationBarHeight), size: CGSize(width: layout.size.width, height: CGFloat(self.caseNodes.count) * 44.0))) + } + + @objc func dimNodeTap(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + if let dismiss = self.dismiss { + dismiss() + } + } + } +} diff --git a/TelegramUI/PeerMediaCollectionTitleView.swift b/TelegramUI/PeerMediaCollectionTitleView.swift new file mode 100644 index 0000000000..19164d4d1c --- /dev/null +++ b/TelegramUI/PeerMediaCollectionTitleView.swift @@ -0,0 +1,96 @@ +import Foundation +import AsyncDisplayKit +import Display + +private let arrowImage = UIImage(bundleImageName: "Media Grid/TitleViewModeSelectionArrow")?.precomposed() + +final class PeerMediaCollectionTitleView: UIView { + private let toggle: () -> Void + + private let titleNode: ASTextNode + private let arrowView: UIImageView + private let button: HighlightTrackingButton + + private var mediaCollectionInterfaceState = PeerMediaCollectionInterfaceState() + + init(toggle: @escaping () -> Void) { + self.toggle = toggle + + self.titleNode = ASTextNode() + self.titleNode.displaysAsynchronously = false + self.titleNode.maximumNumberOfLines = 1 + self.titleNode.truncationMode = .byTruncatingTail + self.titleNode.isOpaque = false + + self.arrowView = UIImageView(image: arrowImage) + + self.button = HighlightTrackingButton(frame: CGRect()) + + super.init(frame: CGRect()) + + self.titleNode.attributedText = NSAttributedString(string: titleForPeerMediaCollectionMode(self.mediaCollectionInterfaceState.mode), font: Font.medium(17.0), textColor: UIColor.black) + self.addSubnode(self.titleNode) + self.addSubview(self.arrowView) + + self.button.highligthedChanged = { [weak self] highlighted in + if let strongSelf = self { + if highlighted { + strongSelf.titleNode.layer.removeAnimation(forKey: "opacity") + strongSelf.arrowView.layer.removeAnimation(forKey: "opacity") + strongSelf.titleNode.alpha = 0.4 + strongSelf.arrowView.alpha = 0.4 + } else { + strongSelf.titleNode.alpha = 1.0 + strongSelf.arrowView.alpha = 1.0 + strongSelf.titleNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + strongSelf.arrowView.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + } + } + } + self.button.addTarget(self, action: #selector(self.buttonPressed), for: [.touchUpInside]) + self.addSubview(self.button) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + let size = self.bounds.size + + self.button.frame = CGRect(origin: CGPoint(), size: size) + + let arrowSize = self.arrowView.bounds.size + let titleArrowSpacing: CGFloat = 4.0 + let titleSize = self.titleNode.measure(CGSize(width: size.width - arrowSize.width - titleArrowSpacing, height: size.height)) + + let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: floor((size.height - titleSize.height) / 2.0)), size: titleSize) + self.titleNode.frame = titleFrame + self.arrowView.frame = CGRect(origin: CGPoint(x: titleFrame.maxX + titleArrowSpacing, y: titleFrame.minY + floor((titleSize.height - arrowSize.height) / 2.0 + 2.0)), size: arrowSize) + } + + func updateMediaCollectionInterfaceState(_ mediaCollectionInterfaceState: PeerMediaCollectionInterfaceState, animated: Bool) { + if self.mediaCollectionInterfaceState != mediaCollectionInterfaceState { + if mediaCollectionInterfaceState.mode != self.mediaCollectionInterfaceState.mode { + self.titleNode.attributedText = NSAttributedString(string: titleForPeerMediaCollectionMode(mediaCollectionInterfaceState.mode), font: Font.medium(17.0), textColor: UIColor.black) + self.setNeedsLayout() + } + + if mediaCollectionInterfaceState.selectingMode != self.mediaCollectionInterfaceState.selectingMode { + let previousSelectingMode = self.mediaCollectionInterfaceState.selectingMode + let arrowTransform = CATransform3DMakeScale(1.0, previousSelectingMode ? 1.0 : -1.0, 1.0) + if animated { + self.arrowView.layer.animate(from: NSNumber(value: Float(previousSelectingMode ? -1.0 : 1.0)), to: NSNumber(value: Float(previousSelectingMode ? 1.0 : -1.0)), keyPath: "transform.scale.y", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.3) + } + self.arrowView.layer.transform = arrowTransform + } + self.mediaCollectionInterfaceState = mediaCollectionInterfaceState + } + } + + @objc func buttonPressed() { + self.toggle() + } +} diff --git a/TelegramUI/PhotoResources.swift b/TelegramUI/PhotoResources.swift index 02874ca9dd..7e3ac92749 100644 --- a/TelegramUI/PhotoResources.swift +++ b/TelegramUI/PhotoResources.swift @@ -11,8 +11,8 @@ func largestRepresentationForPhoto(_ photo: TelegramMediaImage) -> TelegramMedia return photo.representationForDisplayAtSize(CGSize(width: 1280.0, height: 1280.0)) } -private func chatMessagePhotoDatas(account: Account, photo: TelegramMediaImage) -> Signal<(Data?, Data?, Int), NoError> { - if let smallestRepresentation = smallestImageRepresentation(photo.representations), let largestRepresentation = largestRepresentationForPhoto(photo), let smallestSize = smallestRepresentation.size, let largestSize = largestRepresentation.size { +private func chatMessagePhotoDatas(account: Account, photo: TelegramMediaImage, fullRepresentationSize: CGSize = CGSize(width: 1280.0, height: 1280.0), autoFetchFullSize: Bool = false) -> Signal<(Data?, Data?, Int), NoError> { + if let smallestRepresentation = smallestImageRepresentation(photo.representations), let largestRepresentation = photo.representationForDisplayAtSize(fullRepresentationSize), let smallestSize = smallestRepresentation.size, let largestSize = largestRepresentation.size { let thumbnailResource = CloudFileMediaResource(location: smallestRepresentation.location, size: smallestSize) let fullSizeResource = CloudFileMediaResource(location: largestRepresentation.location, size: largestSize) @@ -25,6 +25,7 @@ private func chatMessagePhotoDatas(account: Account, photo: TelegramMediaImage) return .single((nil, loadedData, fullSizeResource.size)) } else { let fetchedThumbnail = account.postbox.mediaBox.fetchedResource(thumbnailResource) + let fetchedFullSize = account.postbox.mediaBox.fetchedResource(fullSizeResource) let thumbnail = Signal { subscriber in let fetchedDisposable = fetchedThumbnail.start() @@ -38,10 +39,28 @@ private func chatMessagePhotoDatas(account: Account, photo: TelegramMediaImage) } } - let fullSizeData = account.postbox.mediaBox.resourceData(fullSizeResource) |> map { next in - return next.size == 0 ? nil : try? Data(contentsOf: URL(fileURLWithPath: next.path), options: .mappedIfSafe) + let fullSizeData: Signal + + if autoFetchFullSize { + fullSizeData = Signal { subscriber in + let fetchedFullSizeDisposable = fetchedFullSize.start() + let fullSizeDisposable = account.postbox.mediaBox.resourceData(fullSizeResource).start(next: { next in + subscriber.putNext(next.size == 0 ? nil : try? Data(contentsOf: URL(fileURLWithPath: next.path), options: [])) + }, error: subscriber.putError, completed: subscriber.putCompletion) + + return ActionDisposable { + fetchedFullSizeDisposable.dispose() + fullSizeDisposable.dispose() + } + } + } else { + fullSizeData = account.postbox.mediaBox.resourceData(fullSizeResource) + |> map { next -> Data? in + return next.size == 0 ? nil : try? Data(contentsOf: URL(fileURLWithPath: next.path), options: []) + } } + return thumbnail |> mapToSignal { thumbnailData in return fullSizeData |> map { fullSizeData in return (thumbnailData, fullSizeData, fullSizeResource.size) @@ -428,6 +447,84 @@ func chatMessagePhoto(account: Account, photo: TelegramMediaImage) -> Signal<(Tr } } +func mediaGridMessagePhoto(account: Account, photo: TelegramMediaImage) -> Signal<(TransformImageArguments) -> DrawingContext, NoError> { + let signal = chatMessagePhotoDatas(account: account, photo: photo, fullRepresentationSize: CGSize(width: 127.0, height: 127.0), autoFetchFullSize: true) + + return signal |> map { (thumbnailData, fullSizeData, fullTotalSize) in + return { arguments in + assertNotOnMainThread() + let context = DrawingContext(size: arguments.drawingSize, clear: true) + + let drawingRect = arguments.drawingRect + let fittedSize = arguments.imageSize.aspectFilled(arguments.boundingSize).fitted(arguments.imageSize) + let fittedRect = CGRect(origin: CGPoint(x: drawingRect.origin.x + (drawingRect.size.width - fittedSize.width) / 2.0, y: drawingRect.origin.y + (drawingRect.size.height - fittedSize.height) / 2.0), size: fittedSize) + + var fullSizeImage: CGImage? + if let fullSizeData = fullSizeData { + if fullSizeData.count >= fullTotalSize { + let options = NSMutableDictionary() + options.setValue(max(fittedSize.width * context.scale, fittedSize.height * context.scale) as NSNumber, forKey: kCGImageSourceThumbnailMaxPixelSize as String) + options.setValue(true as NSNumber, forKey: kCGImageSourceCreateThumbnailFromImageAlways as String) + if let imageSource = CGImageSourceCreateWithData(fullSizeData as CFData, nil), let image = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) { + fullSizeImage = image + } + } else { + let imageSource = CGImageSourceCreateIncremental(nil) + CGImageSourceUpdateData(imageSource, fullSizeData as CFData, fullSizeData.count >= fullTotalSize) + + let options = NSMutableDictionary() + options[kCGImageSourceShouldCache as NSString] = false as NSNumber + if let image = CGImageSourceCreateImageAtIndex(imageSource, 0, options as CFDictionary) { + fullSizeImage = image + } + } + } + + var thumbnailImage: CGImage? + if let thumbnailData = thumbnailData, let imageSource = CGImageSourceCreateWithData(thumbnailData as CFData, nil), let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) { + thumbnailImage = image + } + + var blurredThumbnailImage: UIImage? + if let thumbnailImage = thumbnailImage { + let thumbnailSize = CGSize(width: thumbnailImage.width, height: thumbnailImage.height) + let thumbnailContextSize = thumbnailSize.aspectFitted(CGSize(width: 150.0, height: 150.0)) + let thumbnailContext = DrawingContext(size: thumbnailContextSize, scale: 1.0) + thumbnailContext.withFlippedContext { c in + c.interpolationQuality = .none + c.draw(thumbnailImage, in: CGRect(origin: CGPoint(), size: thumbnailContextSize)) + } + telegramFastBlur(Int32(thumbnailContextSize.width), Int32(thumbnailContextSize.height), Int32(thumbnailContext.bytesPerRow), thumbnailContext.bytes) + + blurredThumbnailImage = thumbnailContext.generateImage() + } + + context.withFlippedContext { c in + c.setBlendMode(.copy) + if arguments.boundingSize != arguments.imageSize { + c.fill(arguments.drawingRect) + } + + c.setBlendMode(.copy) + if let blurredThumbnailImage = blurredThumbnailImage, let cgImage = blurredThumbnailImage.cgImage { + c.interpolationQuality = .low + c.draw(cgImage, in: fittedRect) + c.setBlendMode(.normal) + } + + if let fullSizeImage = fullSizeImage { + c.interpolationQuality = .medium + c.draw(fullSizeImage, in: fittedRect) + } + } + + addCorners(context, arguments: arguments) + + return context + } + } +} + func chatMessagePhotoStatus(account: Account, photo: TelegramMediaImage) -> Signal { if let largestRepresentation = largestRepresentationForPhoto(photo), let largestSize = largestRepresentation.size { let fullSizeResource = CloudFileMediaResource(location: largestRepresentation.location, size: largestSize) diff --git a/TelegramUI/PreparedChatHistoryViewTransition.swift b/TelegramUI/PreparedChatHistoryViewTransition.swift new file mode 100644 index 0000000000..21ab0d2dc7 --- /dev/null +++ b/TelegramUI/PreparedChatHistoryViewTransition.swift @@ -0,0 +1,177 @@ +import Foundation +import SwiftSignalKit +import Postbox +import TelegramCore +import Display + +func preparedChatHistoryViewTransition(from fromView: ChatHistoryView?, to toView: ChatHistoryView, reason: ChatHistoryViewTransitionReason, account: Account, peerId: PeerId, controllerInteraction: ChatControllerInteraction, scrollPosition: ChatHistoryViewScrollPosition?) -> Signal { + return Signal { subscriber in + //let updateIndices: [(Int, ChatHistoryEntry, Int)] = [] + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromView?.filteredEntries ?? [], rightList: toView.filteredEntries) + //let (deleteIndices, indicesAndItems) = mergeListsStable(leftList: fromView?.filteredEntries ?? [], rightList: toView.filteredEntries) + + var adjustedDeleteIndices: [ListViewDeleteItem] = [] + let previousCount: Int + if let fromView = fromView { + previousCount = fromView.filteredEntries.count + } else { + previousCount = 0; + } + for index in deleteIndices { + adjustedDeleteIndices.append(ListViewDeleteItem(index: previousCount - 1 - index, directionHint: nil)) + } + + var adjustedIndicesAndItems: [ChatHistoryViewTransitionInsertEntry] = [] + var adjustedUpdateItems: [ChatHistoryViewTransitionUpdateEntry] = [] + let updatedCount = toView.filteredEntries.count + + var options: ListViewDeleteAndInsertOptions = [] + var maxAnimatedInsertionIndex = -1 + var stationaryItemRange: (Int, Int)? + var scrollToItem: ListViewScrollToItem? + + switch reason { + case let .Initial(fadeIn): + if fadeIn { + let _ = options.insert(.AnimateAlpha) + } else { + let _ = options.insert(.LowLatency) + let _ = options.insert(.Synchronous) + } + case .InteractiveChanges: + let _ = options.insert(.AnimateAlpha) + let _ = options.insert(.AnimateInsertion) + + for (index, _, _) in indicesAndItems.sorted(by: { $0.0 > $1.0 }) { + let adjustedIndex = updatedCount - 1 - index + if adjustedIndex == maxAnimatedInsertionIndex + 1 { + maxAnimatedInsertionIndex += 1 + } + } + case .Reload: + break + case let .HoleChanges(filledHoleDirections, removeHoleDirections): + if let (_, removeDirection) = removeHoleDirections.first { + switch removeDirection { + case .LowerToUpper: + var holeIndex: MessageIndex? + for (index, _) in filledHoleDirections { + if holeIndex == nil || index < holeIndex! { + holeIndex = index + } + } + + if let holeIndex = holeIndex { + for i in 0 ..< toView.filteredEntries.count { + if toView.filteredEntries[i].index >= holeIndex { + let index = toView.filteredEntries.count - 1 - (i - 1) + stationaryItemRange = (index, Int.max) + break + } + } + } + case .UpperToLower: + break + case .AroundIndex: + break + } + } + } + + for (index, entry, previousIndex) in indicesAndItems { + let adjustedIndex = updatedCount - 1 - index + + let adjustedPrevousIndex: Int? + if let previousIndex = previousIndex { + adjustedPrevousIndex = previousCount - 1 - previousIndex + } else { + adjustedPrevousIndex = nil + } + + var directionHint: ListViewItemOperationDirectionHint? + if maxAnimatedInsertionIndex >= 0 && adjustedIndex <= maxAnimatedInsertionIndex { + directionHint = .Down + } + + adjustedIndicesAndItems.append(ChatHistoryViewTransitionInsertEntry(index: adjustedIndex, previousIndex: adjustedPrevousIndex, entry: entry, directionHint: directionHint)) + + /*switch entry { + case let .MessageEntry(message): + adjustedIndicesAndItems.append(ListViewInsertItem(index: adjustedIndex, previousIndex: adjustedPrevousIndex, item: ChatMessageItem(account: account, peerId: peerId, controllerInteraction: controllerInteraction, message: message), directionHint: directionHint)) + case .HoleEntry: + adjustedIndicesAndItems.append(ListViewInsertItem(index: adjustedIndex, previousIndex: adjustedPrevousIndex, item: ChatHoleItem(), directionHint: directionHint)) + case .UnreadEntry: + adjustedIndicesAndItems.append(ListViewInsertItem(index: adjustedIndex, previousIndex: adjustedPrevousIndex, item: ChatUnreadItem(), directionHint: directionHint)) + }*/ + } + + for (index, entry, previousIndex) in updateIndices { + let adjustedIndex = updatedCount - 1 - index + let adjustedPreviousIndex = previousCount - 1 - previousIndex + + let directionHint: ListViewItemOperationDirectionHint? = nil + adjustedUpdateItems.append(ChatHistoryViewTransitionUpdateEntry(index: adjustedIndex, previousIndex: adjustedPreviousIndex, entry: entry, directionHint: directionHint)) + } + + if let scrollPosition = scrollPosition { + switch scrollPosition { + case let .Unread(unreadIndex): + var index = toView.filteredEntries.count - 1 + for entry in toView.filteredEntries { + if case .UnreadEntry = entry { + scrollToItem = ListViewScrollToItem(index: index, position: .Bottom, animated: false, curve: .Default, directionHint: .Down) + break + } + index -= 1 + } + + if scrollToItem == nil { + var index = toView.filteredEntries.count - 1 + for entry in toView.filteredEntries { + if entry.index >= unreadIndex { + scrollToItem = ListViewScrollToItem(index: index, position: .Bottom, animated: false, curve: .Default, directionHint: .Down) + break + } + index -= 1 + } + } + + if scrollToItem == nil { + var index = 0 + for entry in toView.filteredEntries.reversed() { + if entry.index < unreadIndex { + scrollToItem = ListViewScrollToItem(index: index, position: .Bottom, animated: false, curve: .Default, directionHint: .Down) + break + } + index += 1 + } + } + case let .Index(scrollIndex, position, directionHint, animated): + var index = toView.filteredEntries.count - 1 + for entry in toView.filteredEntries { + if entry.index >= scrollIndex { + scrollToItem = ListViewScrollToItem(index: index, position: position, animated: animated, curve: .Default, directionHint: directionHint) + break + } + index -= 1 + } + + if scrollToItem == nil { + var index = 0 + for entry in toView.filteredEntries.reversed() { + if entry.index < scrollIndex { + scrollToItem = ListViewScrollToItem(index: index, position: position, animated: animated, curve: .Default, directionHint: directionHint) + break + } + index += 1 + } + } + } + } + + subscriber.putNext(ChatHistoryViewTransition(historyView: toView, deleteItems: adjustedDeleteIndices, insertEntries: adjustedIndicesAndItems, updateEntries: adjustedUpdateItems, options: options, scrollToItem: scrollToItem, stationaryItemRange: stationaryItemRange)) + subscriber.putCompletion() + + return EmptyDisposable + } +} diff --git a/TelegramUI/RadialProgressNode.swift b/TelegramUI/RadialProgressNode.swift index 5fe5fa8c70..e4daeeb4b4 100644 --- a/TelegramUI/RadialProgressNode.swift +++ b/TelegramUI/RadialProgressNode.swift @@ -52,7 +52,7 @@ private class RadialProgressOverlayNode: ASDisplayNode { return RadialProgressOverlayParameters(theme: self.theme, diameter: self.frame.size.width, state: self.state) } - @objc override class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol!, isCancelled: asdisplaynode_iscancelled_block_t, isRasterizing: Bool) { + @objc override class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: @escaping asdisplaynode_iscancelled_block_t, isRasterizing: Bool) { let context = UIGraphicsGetCurrentContext()! if !isRasterizing { @@ -198,7 +198,7 @@ class RadialProgressNode: ASControlNode { return RadialProgressParameters(theme: self.theme, diameter: self.frame.size.width, state: self.state) } - @objc override class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol!, isCancelled: asdisplaynode_iscancelled_block_t, isRasterizing: Bool) { + @objc override class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: @escaping asdisplaynode_iscancelled_block_t, isRasterizing: Bool) { let context = UIGraphicsGetCurrentContext()! if !isRasterizing { diff --git a/TelegramUI/SettingsAccountInfoItem.swift b/TelegramUI/SettingsAccountInfoItem.swift index 662f46ea32..208f0fda99 100644 --- a/TelegramUI/SettingsAccountInfoItem.swift +++ b/TelegramUI/SettingsAccountInfoItem.swift @@ -78,7 +78,7 @@ class SettingsAccountInfoItemNode: ListControllerGroupableItemNode { statusColor = UIColor(0xb3b3b3) case .Online: statusText = "online" - statusColor = UIColor.blue + statusColor = UIColor(0x1195f2) } let (statusNodeLayout, statusNodeApply) = layoutStatusNode(NSAttributedString(string: statusText, font: statusFont, textColor: statusColor), nil, 1, .end, CGSize(width: width - 20, height: CGFloat.greatestFiniteMagnitude), nil) diff --git a/TelegramUI/TextNode.swift b/TelegramUI/TextNode.swift index 7ca4e29a5c..1596f45aa2 100644 --- a/TelegramUI/TextNode.swift +++ b/TelegramUI/TextNode.swift @@ -207,7 +207,7 @@ final class TextNode: ASDisplayNode { return self.cachedLayout } - @objc override class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol!, isCancelled: asdisplaynode_iscancelled_block_t, isRasterizing: Bool) { + @objc override class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: @escaping asdisplaynode_iscancelled_block_t, isRasterizing: Bool) { let context = UIGraphicsGetCurrentContext()! context.setAllowsAntialiasing(true) diff --git a/TelegramUI/UserInfoController.swift b/TelegramUI/UserInfoController.swift new file mode 100644 index 0000000000..b5e975dd7b --- /dev/null +++ b/TelegramUI/UserInfoController.swift @@ -0,0 +1,386 @@ +import Foundation +import Display +import Postbox +import SwiftSignalKit +import TelegramCore + +private enum UserInfoSection: UInt32 { + case info + case actions + case sharedMediaAndNotifications + case block +} + +private enum UserInfoEntry: Comparable, Identifiable { + case info(peer: Peer?, cachedData: CachedPeerData?) + case about(text: String) + case phoneNumber(index: Int, value: PhoneNumberWithLabel) + case userName(value: String) + case sendMessage + case shareContact + case startSecretChat + case sharedMedia + case notifications(settings: PeerNotificationSettings?) + case block + + fileprivate var section: UserInfoSection { + switch self { + case .info, .about, .phoneNumber, .userName: + return .info + case .sendMessage, .shareContact, .startSecretChat: + return .actions + case .sharedMedia, .notifications: + return .sharedMediaAndNotifications + case .block: + return .block + } + } + + fileprivate var stableId: Int { + return self.sortIndex + } + + fileprivate static func ==(lhs: UserInfoEntry, rhs: UserInfoEntry) -> Bool { + switch lhs { + case let .info(lhsPeer, lhsCachedData): + switch rhs { + case let .info(rhsPeer, rhsCachedData): + if let lhsPeer = lhsPeer, let rhsPeer = rhsPeer { + if !lhsPeer.isEqual(rhsPeer) { + return false + } + } else if (lhsPeer == nil) != (rhsPeer != nil) { + return false + } + if let lhsCachedData = lhsCachedData, let rhsCachedData = rhsCachedData { + if !lhsCachedData.isEqual(to: rhsCachedData) { + return false + } + } else if (rhsCachedData == nil) != (rhsCachedData != nil) { + return false + } + return true + default: + return false + } + case let .about(lhsText): + switch rhs { + case let .about(lhsText): + return true + default: + return false + } + case let .phoneNumber(lhsIndex, lhsValue): + switch rhs { + case let .phoneNumber(rhsIndex, rhsValue) where lhsIndex == rhsIndex && lhsValue == rhsValue: + return true + default: + return false + } + case let .userName(value): + switch rhs { + case .userName(value): + return true + default: + return false + } + case .sendMessage: + switch rhs { + case .sendMessage: + return true + default: + return false + } + case .shareContact: + switch rhs { + case .shareContact: + return true + default: + return false + } + case .startSecretChat: + switch rhs { + case .startSecretChat: + return true + default: + return false + } + case .sharedMedia: + switch rhs { + case .sharedMedia: + return true + default: + return false + } + case let .notifications(lhsSettings): + switch rhs { + case let .notifications(rhsSettings): + if let lhsSettings = lhsSettings, let rhsSettings = rhsSettings { + return lhsSettings.isEqual(to: rhsSettings) + } else if (lhsSettings != nil) != (rhsSettings != nil) { + return false + } + return true + default: + return false + } + case .block: + switch rhs { + case .block: + return true + default: + return false + } + } + } + + private var sortIndex: Int { + switch self { + case .info: + return 0 + case .about: + return 1 + case let .phoneNumber(index, _): + return 2 + index + case .userName: + return 1000 + case .sendMessage: + return 1001 + case .shareContact: + return 1002 + case .startSecretChat: + return 1003 + case .sharedMedia: + return 1004 + case .notifications: + return 1005 + case .block: + return 1006 + } + } + + fileprivate static func <(lhs: UserInfoEntry, rhs: UserInfoEntry) -> Bool { + return lhs.sortIndex < rhs.sortIndex + } +} + +private func userInfoEntries(account: Account, peerId: PeerId) -> Signal<[UserInfoEntry], NoError> { + return account.viewTracker.peerView(peerId) + |> map { view -> [UserInfoEntry] in + var entries: [UserInfoEntry] = [] + entries.append(.info(peer: view.peers[peerId], cachedData: view.cachedData)) + if let cachedUserData = view.cachedData as? CachedUserData { + if let about = cachedUserData.about, !about.isEmpty { + entries.append(.about(text: about)) + } + } + if let user = view.peers[peerId] as? TelegramUser { + if let phoneNumber = user.phone, !phoneNumber.isEmpty { + entries.append(.phoneNumber(index: 0, value: PhoneNumberWithLabel(label: "home", number: phoneNumber))) + } + if let username = user.username, !username.isEmpty { + entries.append(.userName(value: username)) + } + entries.append(.sendMessage) + entries.append(.shareContact) + entries.append(.startSecretChat) + entries.append(.sharedMedia) + entries.append(.notifications(settings: view.notificationSettings)) + entries.append(.block) + } + return entries + } +} + +private struct UserInfoEntryTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] +} + +private func infoItemForEntry(account: Account, entry: UserInfoEntry, interaction: PeerInfoControllerInteraction) -> ListViewItem { + switch entry { + case let .info(peer, cachedData): + return PeerInfoAvatarAndNameItem(account: account, peer: peer, cachedData: cachedData, sectionId: entry.section.rawValue) + case let .about(text): + return PeerInfoTextWithLabelItem(label: "about", text: text, multiline: true, sectionId: entry.section.rawValue) + case let .phoneNumber(_, value): + return PeerInfoTextWithLabelItem(label: value.label, text: formatPhoneNumber(value.number), multiline: false, sectionId: entry.section.rawValue) + case let .userName(value): + return PeerInfoTextWithLabelItem(label: "username", text: "@\(value)", multiline: false, sectionId: entry.section.rawValue) + case .sendMessage: + return PeerInfoActionItem(title: "Send Message", kind: .generic, sectionId: entry.section.rawValue, action: { + + }) + case .shareContact: + return PeerInfoActionItem(title: "Share Contact", kind: .generic, sectionId: entry.section.rawValue, action: { + + }) + case .startSecretChat: + return PeerInfoActionItem(title: "Start Secret Chat", kind: .generic, sectionId: entry.section.rawValue, action: { + + }) + case .sharedMedia: + return PeerInfoDisclosureItem(title: "Shared Media", label: "", sectionId: entry.section.rawValue, action: { + interaction.openSharedMedia() + }) + case let .notifications(settings): + let label: String + if let settings = settings as? TelegramPeerNotificationSettings, case .muted = settings.muteState { + label = "Disabled" + } else { + label = "Enabled" + } + return PeerInfoDisclosureItem(title: "Notifications", label: label, sectionId: entry.section.rawValue, action: { + interaction.changeNotificationNoteSettings() + }) + case .block: + return PeerInfoActionItem(title: "Block User", kind: .destructive, sectionId: entry.section.rawValue, action: { + + }) + } +} + +private func preparedUserInfoEntryTransition(account: Account, from fromEntries: [UserInfoEntry], to toEntries: [UserInfoEntry], interaction: PeerInfoControllerInteraction) -> UserInfoEntryTransition { + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) + + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: infoItemForEntry(account: account, entry: $0.1, interaction: interaction), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: infoItemForEntry(account: account, entry: $0.1, interaction: interaction), directionHint: nil) } + + return UserInfoEntryTransition(deletions: deletions, insertions: insertions, updates: updates) +} + +final class PeerInfoControllerInteraction { + let openSharedMedia: () -> Void + let changeNotificationNoteSettings: () -> Void + + init(openSharedMedia: @escaping () -> Void, changeNotificationNoteSettings: @escaping () -> Void) { + self.openSharedMedia = openSharedMedia + self.changeNotificationNoteSettings = changeNotificationNoteSettings + } +} + +public class UserInfoController: ListController { + private let account: Account + private let peerId: PeerId + + private var _ready = Promise() + override public var ready: Promise { + return self._ready + } + private var didSetReady = false + + private let transitionDisposable = MetaDisposable() + private let changeSettingsDisposable = MetaDisposable() + + public init(account: Account, peerId: PeerId) { + self.account = account + self.peerId = peerId + + super.init() + + self.title = "Info" + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.transitionDisposable.dispose() + self.changeSettingsDisposable.dispose() + } + + override public func displayNodeDidLoad() { + super.displayNodeDidLoad() + + let interaction = PeerInfoControllerInteraction(openSharedMedia: { [weak self] in + if let strongSelf = self { + if let controller = peerSharedMediaController(account: strongSelf.account, peerId: strongSelf.peerId) { + (strongSelf.navigationController as? NavigationController)?.pushViewController(controller) + } + } + }, changeNotificationNoteSettings: { [weak self] in + if let strongSelf = self { + let controller = ActionSheetController() + let dismissAction: () -> Void = { [weak controller] in + controller?.dismissAnimated() + } + let notificationAction: (Int32) -> Void = { [weak strongSelf] muteUntil in + if let strongSelf = strongSelf { + let muteState: PeerMuteState + if muteUntil <= 0 { + muteState = .unmuted + } else if muteUntil == Int32.max { + muteState = .muted(until: Int32.max) + } else { + muteState = .muted(until: Int32(Date().timeIntervalSince1970) + muteUntil) + } + strongSelf.changeSettingsDisposable.set(changePeerNotificationSettings(account: strongSelf.account, peerId: strongSelf.peerId, settings: TelegramPeerNotificationSettings(muteState: muteState, messageSound: PeerMessageSound.appDefault)).start()) + } + } + controller.setItemGroups([ + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "Enable", action: { + dismissAction() + notificationAction(0) + }), + ActionSheetButtonItem(title: "Mute for 1 hour", action: { + dismissAction() + notificationAction(1 * 60 * 60) + }), + ActionSheetButtonItem(title: "Mute for 8 hours", action: { + dismissAction() + notificationAction(8 * 60 * 60) + }), + ActionSheetButtonItem(title: "Mute for 2 days", action: { + dismissAction() + notificationAction(2 * 24 * 60 * 60) + }), + ActionSheetButtonItem(title: "Disable", action: { + dismissAction() + notificationAction(Int32.max) + }) + ]), + ActionSheetItemGroup(items: [ActionSheetButtonItem(title: "Cancel", action: { dismissAction() })]) + ]) + strongSelf.present(controller, in: .window) + } + }) + + self.listDisplayNode.backgroundColor = UIColor.white + + let previousEntries = Atomic<[UserInfoEntry]?>(value: nil) + + let account = self.account + let transition = userInfoEntries(account: self.account, peerId: self.peerId) + |> map { entries -> (UserInfoEntryTransition, Bool, Bool) in + let previous = previousEntries.swap(entries) + return (preparedUserInfoEntryTransition(account: account, from: previous ?? [], to: entries, interaction: interaction), previous == nil, previous != nil) + } + |> deliverOnMainQueue + + self.transitionDisposable.set(transition.start(next: { [weak self] (transition, firstTime, animated) in + self?.enqueueTransition(transition, firstTime: firstTime, animated: animated) + })) + } + + private func enqueueTransition(_ transition: UserInfoEntryTransition, firstTime: Bool, animated: Bool) { + var options = ListViewDeleteAndInsertOptions() + if firstTime { + options.insert(.Synchronous) + options.insert(.LowLatency) + } else if animated { + options.insert(.AnimateInsertion) + } + self.listDisplayNode.listView.deleteAndInsertItems(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, completion: { [weak self] _ in + if let strongSelf = self { + if !strongSelf.didSetReady { + strongSelf.didSetReady = true + strongSelf._ready.set(.single(true)) + } + } + }) + } +}