diff --git a/TelegramUI.xcodeproj/project.pbxproj b/TelegramUI.xcodeproj/project.pbxproj index 831028d2d0..2a8b095a49 100644 --- a/TelegramUI.xcodeproj/project.pbxproj +++ b/TelegramUI.xcodeproj/project.pbxproj @@ -20,6 +20,13 @@ D021E0D01DB413BC00C6B04F /* ChatInputNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D021E0CF1DB413BC00C6B04F /* ChatInputNode.swift */; }; D021E0D21DB4147500C6B04F /* ChatInterfaceInputNodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D021E0D11DB4147500C6B04F /* ChatInterfaceInputNodes.swift */; }; D021E0E51DB55D0A00C6B04F /* ChatMediaInputStickerPackItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D021E0E41DB55D0A00C6B04F /* ChatMediaInputStickerPackItem.swift */; }; + D02383701DDF0462004018B6 /* UrlHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = D023836F1DDF0462004018B6 /* UrlHandling.swift */; }; + D02383731DDF0D8A004018B6 /* ChatInfoTitlePanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02383721DDF0D8A004018B6 /* ChatInfoTitlePanelNode.swift */; }; + D02383751DDF0E5E004018B6 /* ChatInterfaceTitlePanelNodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02383741DDF0E5E004018B6 /* ChatInterfaceTitlePanelNodes.swift */; }; + D02383771DDF16B2004018B6 /* ChatControllerTitlePanelNodeContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02383761DDF16B2004018B6 /* ChatControllerTitlePanelNodeContainer.swift */; }; + D02383791DDF1A4D004018B6 /* ChatRequestInProgressTitlePanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02383781DDF1A4D004018B6 /* ChatRequestInProgressTitlePanelNode.swift */; }; + D023837E1DDF50FD004018B6 /* ChatToastAlertPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D023837D1DDF50FD004018B6 /* ChatToastAlertPanelNode.swift */; }; + D02383841DDFA22C004018B6 /* ListMessageHoleItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02383831DDFA22C004018B6 /* ListMessageHoleItem.swift */; }; D023EBB21DDA800700BD496D /* LegacyMediaPickers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D023EBB11DDA800700BD496D /* LegacyMediaPickers.swift */; }; D023ED2E1DDB5BEC00BD496D /* LegacyAttachmentMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = D023ED2D1DDB5BEC00BD496D /* LegacyAttachmentMenu.swift */; }; D023ED301DDB605D00BD496D /* LegacyEmptyController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D023ED2F1DDB605D00BD496D /* LegacyEmptyController.swift */; }; @@ -83,10 +90,14 @@ D0BA6F881D784F880034826E /* ChatMessageSelectionInputPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BA6F871D784F880034826E /* ChatMessageSelectionInputPanelNode.swift */; }; D0D268671D78793B00C422DA /* ChatInterfaceStateNavigationButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D268661D78793B00C422DA /* ChatInterfaceStateNavigationButtons.swift */; }; D0D268691D78865300C422DA /* ChatAvatarNavigationNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D268681D78865300C422DA /* ChatAvatarNavigationNode.swift */; }; - D0D2686C1D788F8200C422DA /* NavigationAccessoryPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2686B1D788F8200C422DA /* NavigationAccessoryPanelNode.swift */; }; + D0D2686C1D788F8200C422DA /* ChatTitleAccessoryPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2686B1D788F8200C422DA /* ChatTitleAccessoryPanelNode.swift */; }; D0D2686E1D7898A900C422DA /* ChatMessageSelectionNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2686D1D7898A900C422DA /* ChatMessageSelectionNode.swift */; }; D0D2689A1D79CF9F00C422DA /* ChatPanelInterfaceInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D268991D79CF9F00C422DA /* ChatPanelInterfaceInteraction.swift */; }; D0D2689D1D79D33E00C422DA /* ShareRecipientsActionSheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2689C1D79D33E00C422DA /* ShareRecipientsActionSheetController.swift */; }; + D0DC35441DE32230000195EB /* ChatInterfaceStateContextQueries.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC35431DE32230000195EB /* ChatInterfaceStateContextQueries.swift */; }; + D0DC35461DE35805000195EB /* MentionChatInputPanelItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC35451DE35805000195EB /* MentionChatInputPanelItem.swift */; }; + D0DC354A1DE366CD000195EB /* CommandChatInputContextPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC35491DE366CD000195EB /* CommandChatInputContextPanelNode.swift */; }; + D0DC354C1DE366DE000195EB /* CommandChatInputPanelItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC354B1DE366DE000195EB /* CommandChatInputPanelItem.swift */; }; D0DE76F71D91BA3D002B8809 /* GridHoleItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DE76F61D91BA3D002B8809 /* GridHoleItem.swift */; }; D0DE76FE1D92EFF2002B8809 /* PeerMediaCollectionTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DE76FD1D92EFF2002B8809 /* PeerMediaCollectionTitleView.swift */; }; D0DE77001D92F1EB002B8809 /* ChatTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DE76FF1D92F1EB002B8809 /* ChatTitleView.swift */; }; @@ -102,15 +113,17 @@ D0DF0C9A1D81FF3F008AEB01 /* ChatInputContextPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DF0C991D81FF3F008AEB01 /* ChatInputContextPanelNode.swift */; }; D0DF0C9C1D81FFB2008AEB01 /* ChatInterfaceInputContextPanels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DF0C9B1D81FFB2008AEB01 /* ChatInterfaceInputContextPanels.swift */; }; D0DF0C9E1D82141F008AEB01 /* ChatInterfaceInputContexts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DF0C9D1D82141F008AEB01 /* ChatInterfaceInputContexts.swift */; }; - D0DF0CA11D821B28008AEB01 /* HashtagsTableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DF0CA01D821B28008AEB01 /* HashtagsTableCell.swift */; }; + D0DF0CA11D821B28008AEB01 /* HashtagChatInputPanelItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DF0CA01D821B28008AEB01 /* HashtagChatInputPanelItem.swift */; }; D0DF0CA41D82BCD0008AEB01 /* MentionChatInputContextPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DF0CA31D82BCD0008AEB01 /* MentionChatInputContextPanelNode.swift */; }; - D0DF0CA61D82BCE0008AEB01 /* MentionsTableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DF0CA51D82BCE0008AEB01 /* MentionsTableCell.swift */; }; + D0E35A071DE4803400BC6096 /* VerticalListContextResultsChatInputContextPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E35A061DE4803400BC6096 /* VerticalListContextResultsChatInputContextPanelNode.swift */; }; + D0E35A091DE4804900BC6096 /* VerticalListContextResultsChatInputPanelItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E35A081DE4804900BC6096 /* VerticalListContextResultsChatInputPanelItem.swift */; }; D0E7A1BD1D8C246D00C37A6F /* ChatHistoryListNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E7A1BC1D8C246D00C37A6F /* ChatHistoryListNode.swift */; }; D0E7A1BF1D8C24B900C37A6F /* ChatHistoryViewForLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E7A1BE1D8C24B900C37A6F /* ChatHistoryViewForLocation.swift */; }; D0E7A1C11D8C258D00C37A6F /* ChatHistoryEntriesForView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E7A1C01D8C258D00C37A6F /* ChatHistoryEntriesForView.swift */; }; D0E7A1C31D8C25D600C37A6F /* PreparedChatHistoryViewTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E7A1C21D8C25D600C37A6F /* PreparedChatHistoryViewTransition.swift */; }; D0ED5D4B1DC806D7007CBB15 /* ApplicationSpecificData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED5D4A1DC806D7007CBB15 /* ApplicationSpecificData.swift */; }; D0EE971A1D88BCA0006C18E1 /* ChatInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EE97191D88BCA0006C18E1 /* ChatInfo.swift */; }; + D0EFD8961DDE8249009E508A /* LegacyLocationPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EFD8951DDE8249009E508A /* LegacyLocationPicker.swift */; }; D0F69D231D6B87D30046BCD6 /* FFMpegMediaFrameSourceContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F69CD31D6B87D30046BCD6 /* FFMpegMediaFrameSourceContext.swift */; }; D0F69D241D6B87D30046BCD6 /* MediaPlayerAudioRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F69CD41D6B87D30046BCD6 /* MediaPlayerAudioRenderer.swift */; }; D0F69D261D6B87D30046BCD6 /* MediaManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F69CD61D6B87D30046BCD6 /* MediaManager.swift */; }; @@ -269,6 +282,13 @@ D021E0CF1DB413BC00C6B04F /* ChatInputNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInputNode.swift; sourceTree = ""; }; D021E0D11DB4147500C6B04F /* ChatInterfaceInputNodes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInterfaceInputNodes.swift; sourceTree = ""; }; D021E0E41DB55D0A00C6B04F /* ChatMediaInputStickerPackItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMediaInputStickerPackItem.swift; sourceTree = ""; }; + D023836F1DDF0462004018B6 /* UrlHandling.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UrlHandling.swift; sourceTree = ""; }; + D02383721DDF0D8A004018B6 /* ChatInfoTitlePanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInfoTitlePanelNode.swift; sourceTree = ""; }; + D02383741DDF0E5E004018B6 /* ChatInterfaceTitlePanelNodes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInterfaceTitlePanelNodes.swift; sourceTree = ""; }; + D02383761DDF16B2004018B6 /* ChatControllerTitlePanelNodeContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatControllerTitlePanelNodeContainer.swift; sourceTree = ""; }; + D02383781DDF1A4D004018B6 /* ChatRequestInProgressTitlePanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatRequestInProgressTitlePanelNode.swift; sourceTree = ""; }; + D023837D1DDF50FD004018B6 /* ChatToastAlertPanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatToastAlertPanelNode.swift; sourceTree = ""; }; + D02383831DDFA22C004018B6 /* ListMessageHoleItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListMessageHoleItem.swift; sourceTree = ""; }; D023EBB11DDA800700BD496D /* LegacyMediaPickers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyMediaPickers.swift; sourceTree = ""; }; D023ED2D1DDB5BEC00BD496D /* LegacyAttachmentMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyAttachmentMenu.swift; sourceTree = ""; }; D023ED2F1DDB605D00BD496D /* LegacyEmptyController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyEmptyController.swift; sourceTree = ""; }; @@ -335,10 +355,14 @@ D0BA6F871D784F880034826E /* ChatMessageSelectionInputPanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMessageSelectionInputPanelNode.swift; sourceTree = ""; }; D0D268661D78793B00C422DA /* ChatInterfaceStateNavigationButtons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInterfaceStateNavigationButtons.swift; sourceTree = ""; }; D0D268681D78865300C422DA /* ChatAvatarNavigationNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatAvatarNavigationNode.swift; sourceTree = ""; }; - D0D2686B1D788F8200C422DA /* NavigationAccessoryPanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationAccessoryPanelNode.swift; sourceTree = ""; }; + D0D2686B1D788F8200C422DA /* ChatTitleAccessoryPanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatTitleAccessoryPanelNode.swift; sourceTree = ""; }; D0D2686D1D7898A900C422DA /* ChatMessageSelectionNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMessageSelectionNode.swift; sourceTree = ""; }; D0D268991D79CF9F00C422DA /* ChatPanelInterfaceInteraction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatPanelInterfaceInteraction.swift; sourceTree = ""; }; D0D2689C1D79D33E00C422DA /* ShareRecipientsActionSheetController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShareRecipientsActionSheetController.swift; sourceTree = ""; }; + D0DC35431DE32230000195EB /* ChatInterfaceStateContextQueries.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInterfaceStateContextQueries.swift; sourceTree = ""; }; + D0DC35451DE35805000195EB /* MentionChatInputPanelItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MentionChatInputPanelItem.swift; sourceTree = ""; }; + D0DC35491DE366CD000195EB /* CommandChatInputContextPanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommandChatInputContextPanelNode.swift; sourceTree = ""; }; + D0DC354B1DE366DE000195EB /* CommandChatInputPanelItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommandChatInputPanelItem.swift; sourceTree = ""; }; D0DE76F61D91BA3D002B8809 /* GridHoleItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GridHoleItem.swift; sourceTree = ""; }; D0DE76FD1D92EFF2002B8809 /* PeerMediaCollectionTitleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerMediaCollectionTitleView.swift; sourceTree = ""; }; D0DE76FF1D92F1EB002B8809 /* ChatTitleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatTitleView.swift; sourceTree = ""; }; @@ -354,15 +378,17 @@ D0DF0C991D81FF3F008AEB01 /* ChatInputContextPanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInputContextPanelNode.swift; sourceTree = ""; }; D0DF0C9B1D81FFB2008AEB01 /* ChatInterfaceInputContextPanels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInterfaceInputContextPanels.swift; sourceTree = ""; }; D0DF0C9D1D82141F008AEB01 /* ChatInterfaceInputContexts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInterfaceInputContexts.swift; sourceTree = ""; }; - D0DF0CA01D821B28008AEB01 /* HashtagsTableCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HashtagsTableCell.swift; sourceTree = ""; }; + D0DF0CA01D821B28008AEB01 /* HashtagChatInputPanelItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HashtagChatInputPanelItem.swift; sourceTree = ""; }; D0DF0CA31D82BCD0008AEB01 /* MentionChatInputContextPanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MentionChatInputContextPanelNode.swift; sourceTree = ""; }; - D0DF0CA51D82BCE0008AEB01 /* MentionsTableCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MentionsTableCell.swift; sourceTree = ""; }; + D0E35A061DE4803400BC6096 /* VerticalListContextResultsChatInputContextPanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VerticalListContextResultsChatInputContextPanelNode.swift; sourceTree = ""; }; + D0E35A081DE4804900BC6096 /* VerticalListContextResultsChatInputPanelItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VerticalListContextResultsChatInputPanelItem.swift; sourceTree = ""; }; D0E7A1BC1D8C246D00C37A6F /* ChatHistoryListNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatHistoryListNode.swift; sourceTree = ""; }; D0E7A1BE1D8C24B900C37A6F /* ChatHistoryViewForLocation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatHistoryViewForLocation.swift; sourceTree = ""; }; D0E7A1C01D8C258D00C37A6F /* ChatHistoryEntriesForView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatHistoryEntriesForView.swift; sourceTree = ""; }; D0E7A1C21D8C25D600C37A6F /* PreparedChatHistoryViewTransition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreparedChatHistoryViewTransition.swift; sourceTree = ""; }; D0ED5D4A1DC806D7007CBB15 /* ApplicationSpecificData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApplicationSpecificData.swift; sourceTree = ""; }; D0EE97191D88BCA0006C18E1 /* ChatInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInfo.swift; sourceTree = ""; }; + D0EFD8951DDE8249009E508A /* LegacyLocationPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyLocationPicker.swift; sourceTree = ""; }; D0F69CD31D6B87D30046BCD6 /* FFMpegMediaFrameSourceContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FFMpegMediaFrameSourceContext.swift; sourceTree = ""; }; D0F69CD41D6B87D30046BCD6 /* MediaPlayerAudioRenderer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaPlayerAudioRenderer.swift; sourceTree = ""; }; D0F69CD61D6B87D30046BCD6 /* MediaManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaManager.swift; sourceTree = ""; }; @@ -593,6 +619,8 @@ D0DF0C9D1D82141F008AEB01 /* ChatInterfaceInputContexts.swift */, D0DF0C9B1D81FFB2008AEB01 /* ChatInterfaceInputContextPanels.swift */, D021E0D11DB4147500C6B04F /* ChatInterfaceInputNodes.swift */, + D02383741DDF0E5E004018B6 /* ChatInterfaceTitlePanelNodes.swift */, + D0DC35431DE32230000195EB /* ChatInterfaceStateContextQueries.swift */, ); name = "Interface State"; sourceTree = ""; @@ -627,6 +655,7 @@ D00E15251DDBD4E700ACF65C /* LegacyCamera.swift */, D023ED2F1DDB605D00BD496D /* LegacyEmptyController.swift */, D023ED311DDB60CF00BD496D /* LegacyNavigationController.swift */, + D0EFD8951DDE8249009E508A /* LegacyLocationPicker.swift */, ); name = "Legacy Components"; sourceTree = ""; @@ -703,12 +732,15 @@ name = "Input Panels"; sourceTree = ""; }; - D0D2686A1D788F6600C422DA /* Navigation Accessory Panels */ = { + D0D2686A1D788F6600C422DA /* Title Accessory Panels */ = { isa = PBXGroup; children = ( - D0D2686B1D788F8200C422DA /* NavigationAccessoryPanelNode.swift */, + D0D2686B1D788F8200C422DA /* ChatTitleAccessoryPanelNode.swift */, + D02383721DDF0D8A004018B6 /* ChatInfoTitlePanelNode.swift */, + D02383781DDF1A4D004018B6 /* ChatRequestInProgressTitlePanelNode.swift */, + D023837D1DDF50FD004018B6 /* ChatToastAlertPanelNode.swift */, ); - name = "Navigation Accessory Panels"; + name = "Title Accessory Panels"; sourceTree = ""; }; D0D2689B1D79D31500C422DA /* Peer Selection */ = { @@ -721,6 +753,15 @@ name = "Peer Selection"; sourceTree = ""; }; + D0DC35481DE366B4000195EB /* Commands */ = { + isa = PBXGroup; + children = ( + D0DC35491DE366CD000195EB /* CommandChatInputContextPanelNode.swift */, + D0DC354B1DE366DE000195EB /* CommandChatInputPanelItem.swift */, + ); + name = Commands; + sourceTree = ""; + }; D0DE772C1D934DCB002B8809 /* List Items */ = { isa = PBXGroup; children = ( @@ -744,6 +785,7 @@ D07A7DA41D95783C005BCD27 /* ListMessageNode.swift */, D0DE77311D940295002B8809 /* ListMessageFileItemNode.swift */, D07A7DA21D957671005BCD27 /* ListMessageSnippetItemNode.swift */, + D02383831DDFA22C004018B6 /* ListMessageHoleItem.swift */, ); name = List; sourceTree = ""; @@ -754,6 +796,8 @@ D0DF0C991D81FF3F008AEB01 /* ChatInputContextPanelNode.swift */, D0DF0C9F1D8219C7008AEB01 /* Hashtags */, D0DF0CA21D82BCBC008AEB01 /* Mentions */, + D0DC35481DE366B4000195EB /* Commands */, + D0E35A041DE47FFE00BC6096 /* Context Request Results */, ); name = "Input Context Panels"; sourceTree = ""; @@ -762,7 +806,7 @@ isa = PBXGroup; children = ( D0DF0C971D81FF28008AEB01 /* HashtagChatInputContextPanelNode.swift */, - D0DF0CA01D821B28008AEB01 /* HashtagsTableCell.swift */, + D0DF0CA01D821B28008AEB01 /* HashtagChatInputPanelItem.swift */, ); name = Hashtags; sourceTree = ""; @@ -771,11 +815,28 @@ isa = PBXGroup; children = ( D0DF0CA31D82BCD0008AEB01 /* MentionChatInputContextPanelNode.swift */, - D0DF0CA51D82BCE0008AEB01 /* MentionsTableCell.swift */, + D0DC35451DE35805000195EB /* MentionChatInputPanelItem.swift */, ); name = Mentions; sourceTree = ""; }; + D0E35A041DE47FFE00BC6096 /* Context Request Results */ = { + isa = PBXGroup; + children = ( + D0E35A051DE4801600BC6096 /* Vertical List */, + ); + name = "Context Request Results"; + sourceTree = ""; + }; + D0E35A051DE4801600BC6096 /* Vertical List */ = { + isa = PBXGroup; + children = ( + D0E35A061DE4803400BC6096 /* VerticalListContextResultsChatInputContextPanelNode.swift */, + D0E35A081DE4804900BC6096 /* VerticalListContextResultsChatInputPanelItem.swift */, + ); + name = "Vertical List"; + sourceTree = ""; + }; D0E7A1BB1D8C17EB00C37A6F /* Chat History Node */ = { isa = PBXGroup; children = ( @@ -998,6 +1059,7 @@ D0F69E121D6B8ACF0046BCD6 /* ChatHistoryLocation.swift */, D0D268681D78865300C422DA /* ChatAvatarNavigationNode.swift */, D0DE76FF1D92F1EB002B8809 /* ChatTitleView.swift */, + D02383761DDF16B2004018B6 /* ChatControllerTitlePanelNodeContainer.swift */, D0EE97191D88BCA0006C18E1 /* ChatInfo.swift */, D0F69E181D6B8AD10046BCD6 /* Items */, D03ADB461D703250005A521C /* Interface State */, @@ -1005,7 +1067,7 @@ D021E0CC1DB4132E00C6B04F /* Input Nodes */, D0DF0C961D81FD87008AEB01 /* Input Context Panels */, D0BA6F811D784C3A0034826E /* Input Panels */, - D0D2686A1D788F6600C422DA /* Navigation Accessory Panels */, + D0D2686A1D788F6600C422DA /* Title Accessory Panels */, D0F69E441D6B8B850046BCD6 /* History Navigation */, D0F69E471D6B8B9A0046BCD6 /* Input Media Action Sheet */, ); @@ -1168,6 +1230,7 @@ D05811931DD5F9380057C769 /* TelegramApplicationContext.swift */, D04B66B71DD672D00049C3D2 /* GeoLocation.swift */, D00219031DDCC86400BE708A /* PerformanceSpinner.swift */, + D023836F1DDF0462004018B6 /* UrlHandling.swift */, ); name = Utils; sourceTree = ""; @@ -1367,7 +1430,7 @@ D0BA6F831D784C520034826E /* ChatInputPanelNode.swift in Sources */, D0F69DC71D6B89E70046BCD6 /* TransformImageNode.swift in Sources */, D0B844581DAC44E8005F29E1 /* PeerPresenceStatusManager.swift in Sources */, - D0D2686C1D788F8200C422DA /* NavigationAccessoryPanelNode.swift in Sources */, + D0D2686C1D788F8200C422DA /* ChatTitleAccessoryPanelNode.swift in Sources */, D07A7DA51D95783C005BCD27 /* ListMessageNode.swift in Sources */, D0F69E341D6B8B030046BCD6 /* ChatMessageForwardInfoNode.swift in Sources */, D0F69E561D6B8BDA0046BCD6 /* GalleryControllerNode.swift in Sources */, @@ -1378,12 +1441,14 @@ D0B843DB1DAAB138005F29E1 /* PeerInfoPeerActionItem.swift in Sources */, D0F69EA11D6B8E380046BCD6 /* FileResources.swift in Sources */, D0F69D271D6B87D30046BCD6 /* FFMpegAudioFrameDecoder.swift in Sources */, + D0DC354C1DE366DE000195EB /* CommandChatInputPanelItem.swift in Sources */, D073CE651DCBC26B007511FD /* ServiceSoundManager.swift in Sources */, D0F69D521D6B87D30046BCD6 /* MediaPlayer.swift in Sources */, D0F7AB351DCFADCD009AD9A1 /* ChatMessageBubbleImages.swift in Sources */, D0F69E031D6B8A880046BCD6 /* ChatListItem.swift in Sources */, D0F69E081D6B8A9C0046BCD6 /* ChatListSearchContainerNode.swift in Sources */, D04B66B81DD672D00049C3D2 /* GeoLocation.swift in Sources */, + D02383791DDF1A4D004018B6 /* ChatRequestInProgressTitlePanelNode.swift in Sources */, D0DE77291D932923002B8809 /* GridMessageSelectionNode.swift in Sources */, D0F69E4C1D6B8BB20046BCD6 /* ChatMediaActionSheetController.swift in Sources */, D02BE0771D9190EF000889C2 /* GridMessageItem.swift in Sources */, @@ -1414,6 +1479,7 @@ D0DF0C9E1D82141F008AEB01 /* ChatInterfaceInputContexts.swift in Sources */, D0B843CD1DA903BB005F29E1 /* PeerInfoController.swift in Sources */, D0D2686E1D7898A900C422DA /* ChatMessageSelectionNode.swift in Sources */, + D0EFD8961DDE8249009E508A /* LegacyLocationPicker.swift in Sources */, D0F69E8B1D6B8C850046BCD6 /* FFMpegSwResample.m in Sources */, D0B843921DA7F13E005F29E1 /* PeerInfoDisclosureItem.swift in Sources */, D07CFF791DCA226F00761F81 /* ChatListNode.swift in Sources */, @@ -1425,7 +1491,6 @@ D0F69E761D6B8C340046BCD6 /* ContactsSearchContainerNode.swift in Sources */, D023ED301DDB605D00BD496D /* LegacyEmptyController.swift in Sources */, D0B843CF1DA922AD005F29E1 /* PeerInfoEntries.swift in Sources */, - D0DF0CA61D82BCE0008AEB01 /* MentionsTableCell.swift in Sources */, D0B844561DAC3AEE005F29E1 /* PresenceStrings.swift in Sources */, D07551931DDA540F0073E051 /* TelegramInitializeLegacyComponents.swift in Sources */, D0F69E011D6B8A880046BCD6 /* ChatListEmptyItem.swift in Sources */, @@ -1435,6 +1500,7 @@ D0E7A1BD1D8C246D00C37A6F /* ChatHistoryListNode.swift in Sources */, D0F69E371D6B8B030046BCD6 /* ChatMessageItem.swift in Sources */, D023ED2E1DDB5BEC00BD496D /* LegacyAttachmentMenu.swift in Sources */, + D02383771DDF16B2004018B6 /* ChatControllerTitlePanelNodeContainer.swift in Sources */, D00370321DA46C06004308D3 /* PeerInfoTextWithLabelItem.swift in Sources */, D0DF0C9C1D81FFB2008AEB01 /* ChatInterfaceInputContextPanels.swift in Sources */, D0DE77301D934DEF002B8809 /* ListMessageItem.swift in Sources */, @@ -1442,14 +1508,16 @@ D0F69E641D6B8BF90046BCD6 /* ChatVideoGalleryItem.swift in Sources */, D075518D1DDA4E0B0073E051 /* LegacyControllerNode.swift in Sources */, D07CFF7B1DCA24BF00761F81 /* ChatListNodeEntries.swift in Sources */, - D0DF0CA11D821B28008AEB01 /* HashtagsTableCell.swift in Sources */, + D0DF0CA11D821B28008AEB01 /* HashtagChatInputPanelItem.swift in Sources */, D021E0D01DB413BC00C6B04F /* ChatInputNode.swift in Sources */, D0F69E351D6B8B030046BCD6 /* ChatMessageInteractiveFileNode.swift in Sources */, D0F69E151D6B8ACF0046BCD6 /* ChatControllerNode.swift in Sources */, + D02383731DDF0D8A004018B6 /* ChatInfoTitlePanelNode.swift in Sources */, D0F69E001D6B8A880046BCD6 /* ChatListControllerNode.swift in Sources */, D0F69EA31D6B8E380046BCD6 /* StickerResources.swift in Sources */, D0DE77321D940295002B8809 /* ListMessageFileItemNode.swift in Sources */, D06879571DB8F22200424BBD /* FetchCachedRepresentations.swift in Sources */, + D0DC35461DE35805000195EB /* MentionChatInputPanelItem.swift in Sources */, D0F69E961D6B8C9B0046BCD6 /* ProgressiveImage.swift in Sources */, D0F69E621D6B8BF90046BCD6 /* ChatHoleGalleryItem.swift in Sources */, D0F69E331D6B8B030046BCD6 /* ChatMessageFileBubbleContentNode.swift in Sources */, @@ -1466,7 +1534,9 @@ D0F69D771D6B87DF0046BCD6 /* FFMpegMediaPassthroughVideoFrameDecoder.swift in Sources */, D0F69DFE1D6B8A880046BCD6 /* AvatarNode.swift in Sources */, D0F69E9B1D6B8D200046BCD6 /* UIImage+WebP.m in Sources */, + D02383701DDF0462004018B6 /* UrlHandling.swift in Sources */, D05811941DD5F9380057C769 /* TelegramApplicationContext.swift in Sources */, + D02383751DDF0E5E004018B6 /* ChatInterfaceTitlePanelNodes.swift in Sources */, D0ED5D4B1DC806D7007CBB15 /* ApplicationSpecificData.swift in Sources */, D0F69E581D6B8BDA0046BCD6 /* GalleryItemNode.swift in Sources */, D0F69E971D6B8C9B0046BCD6 /* WebP.swift in Sources */, @@ -1479,10 +1549,12 @@ D0DF0C981D81FF28008AEB01 /* HashtagChatInputContextPanelNode.swift in Sources */, D0F69E731D6B8C340046BCD6 /* ContactsController.swift in Sources */, D0F69D261D6B87D30046BCD6 /* MediaManager.swift in Sources */, + D02383841DDFA22C004018B6 /* ListMessageHoleItem.swift in Sources */, D0F69D2C1D6B87D30046BCD6 /* MediaPlayerNode.swift in Sources */, D0DE76F71D91BA3D002B8809 /* GridHoleItem.swift in Sources */, D0F69E311D6B8B030046BCD6 /* ChatMessageBubbleItemNode.swift in Sources */, D0E7A1C31D8C25D600C37A6F /* PreparedChatHistoryViewTransition.swift in Sources */, + D0DC354A1DE366CD000195EB /* CommandChatInputContextPanelNode.swift in Sources */, D0F69E021D6B8A880046BCD6 /* ChatListHoleItem.swift in Sources */, D0F69DF51D6B8A6C0046BCD6 /* AuthorizationPhoneControllerNode.swift in Sources */, D0F69E751D6B8C340046BCD6 /* ContactsPeerItem.swift in Sources */, @@ -1501,6 +1573,7 @@ D0F69E8F1D6B8C850046BCD6 /* RingBuffer.m in Sources */, D0F69DF31D6B8A6C0046BCD6 /* AuthorizationPasswordControllerNode.swift in Sources */, D0F69E131D6B8ACF0046BCD6 /* ChatController.swift in Sources */, + D023837E1DDF50FD004018B6 /* ChatToastAlertPanelNode.swift in Sources */, D0F69DFF1D6B8A880046BCD6 /* ChatListController.swift in Sources */, D0E7A1C11D8C258D00C37A6F /* ChatHistoryEntriesForView.swift in Sources */, D0F69DF11D6B8A6C0046BCD6 /* AuthorizationController.swift in Sources */, @@ -1526,6 +1599,7 @@ D0BA6F881D784F880034826E /* ChatMessageSelectionInputPanelNode.swift in Sources */, D0F69E3B1D6B8B030046BCD6 /* ChatMessageStickerItemNode.swift in Sources */, D0F69DEF1D6B8A6C0046BCD6 /* AuthorizationCodeController.swift in Sources */, + D0E35A091DE4804900BC6096 /* VerticalListContextResultsChatInputPanelItem.swift in Sources */, D01AC9181DD5033100E8160F /* ChatMessageActionButtonsNode.swift in Sources */, D0F69DF41D6B8A6C0046BCD6 /* AuthorizationPhoneController.swift in Sources */, D0DE77231D932043002B8809 /* PeerMediaCollectionInterfaceState.swift in Sources */, @@ -1536,6 +1610,7 @@ D03120F61DA534C1006A2A60 /* PeerInfoActionItem.swift in Sources */, D0F69E781D6B8C340046BCD6 /* ContactsVCardItem.swift in Sources */, D0D268691D78865300C422DA /* ChatAvatarNavigationNode.swift in Sources */, + D0DC35441DE32230000195EB /* ChatInterfaceStateContextQueries.swift in Sources */, D0F69DA51D6B87EC0046BCD6 /* MediaTrackFrameDecoder.swift in Sources */, D023ED321DDB60CF00BD496D /* LegacyNavigationController.swift in Sources */, D0F69E2D1D6B8B030046BCD6 /* ChatMessageActionItemNode.swift in Sources */, @@ -1548,6 +1623,7 @@ D0F69DDF1D6B8A420046BCD6 /* ListController.swift in Sources */, D0F69E3A1D6B8B030046BCD6 /* ChatMessageReplyInfoNode.swift in Sources */, D07CFF741DCA207200761F81 /* PeerSelectionController.swift in Sources */, + D0E35A071DE4803400BC6096 /* VerticalListContextResultsChatInputContextPanelNode.swift in Sources */, D0EE971A1D88BCA0006C18E1 /* ChatInfo.swift in Sources */, D0F69DE31D6B8A420046BCD6 /* ListControllerItem.swift in Sources */, D07A7DA31D957671005BCD27 /* ListMessageSnippetItemNode.swift in Sources */, diff --git a/TelegramUI/ChatController.swift b/TelegramUI/ChatController.swift index 83f33ef649..481bcbbb87 100644 --- a/TelegramUI/ChatController.swift +++ b/TelegramUI/ChatController.swift @@ -23,7 +23,6 @@ public class ChatController: ViewController { private let peerView = Promise() private var presentationInterfaceState = ChatPresentationInterfaceState() - private let chatInterfaceStatePromise = Promise() private var chatTitleView: ChatTitleView? private var leftNavigationButton: ChatNavigationButton? @@ -44,6 +43,11 @@ public class ChatController: ViewController { private let editingMessage = ValuePromise(false, ignoreRepeated: true) + private let botCallbackAlertMessage = Promise(nil) + private var botCallbackAlertMessageDisposable: Disposable? + + private var contextQueryState: (ChatPresentationInputQuery?, Disposable)? + public init(account: Account, peerId: PeerId, messageId: MessageId? = nil) { self.account = account self.peerId = peerId @@ -124,7 +128,97 @@ public class ChatController: ViewController { } }, openPeer: { [weak self] id, navigation in if let strongSelf = self { - (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: id, messageId: nil)) + if id == strongSelf.peerId { + switch navigation { + case .info: + strongSelf.navigationButtonAction(.openChatInfo) + case let .chat(textInputState): + if let textInputState = textInputState { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { + return ($0.updatedInterfaceState { + return $0.withUpdatedComposeInputState(textInputState) + }).updatedInputMode({ _ in + return .text + }) + }) + } + } + } else { + if let id = id { + switch navigation { + case .info: + break + case let .chat(textInputState): + if let textInputState = textInputState { + (strongSelf.account.postbox.modify({ modifier -> Void in + modifier.updatePeerChatInterfaceState(id, update: { currentState in + if let currentState = currentState as? ChatInterfaceState { + return currentState.withUpdatedComposeInputState(textInputState) + } else { + return ChatInterfaceState().withUpdatedComposeInputState(textInputState) + } + return currentState + }) + })).start(completed: { + if let strongSelf = self { + (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: id, messageId: nil)) + } + }) + } else { + (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: id, messageId: nil)) + } + } + } else { + switch navigation { + case .info: + break + case let .chat(textInputState): + if let textInputState = textInputState { + let controller = PeerSelectionController(account: strongSelf.account) + controller.peerSelected = { [weak controller] peerId in + if let strongSelf = self, let strongController = controller { + if peerId == strongSelf.peerId { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { + return ($0.updatedInterfaceState { + return $0.withUpdatedComposeInputState(textInputState) + }).updatedInputMode({ _ in + return .text + }) + }) + strongController.dismiss() + } else { + (strongSelf.account.postbox.modify({ modifier -> Void in + modifier.updatePeerChatInterfaceState(peerId, update: { currentState in + if let currentState = currentState as? ChatInterfaceState { + return currentState.withUpdatedComposeInputState(textInputState) + } else { + return ChatInterfaceState().withUpdatedComposeInputState(textInputState) + } + return currentState + }) + }) |> deliverOnMainQueue).start(completed: { + if let strongSelf = self { + strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: true, { $0.updatedInterfaceState({ $0.withoutSelectionState() }) }) + + let ready = ValuePromise() + + strongSelf.controllerNavigationDisposable.set((ready.get() |> take(1) |> deliverOnMainQueue).start(next: { _ in + if let strongController = controller { + strongController.dismiss() + } + })) + + (strongSelf.navigationController as? NavigationController)?.replaceTopController(ChatController(account: strongSelf.account, peerId: peerId), animated: false, ready: ready) + } + }) + } + } + } + strongSelf.present(controller, in: .window) + } + } + } + } } }, openPeerMention: { [weak self] name in if let strongSelf = self { @@ -192,20 +286,77 @@ public class ChatController: ViewController { }, sendMessage: { [weak self] text in if let strongSelf = self { strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({}) - enqueueMessages(account: strongSelf.account, peerId: strongSelf.peerId, messages: [.message(text: text, media: nil, replyToMessageId: nil)]).start() + enqueueMessages(account: strongSelf.account, peerId: strongSelf.peerId, messages: [.message(text: text, attributes: [], media: nil, replyToMessageId: nil)]).start() } }, sendSticker: { [weak self] file in if let strongSelf = self { strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({}) - enqueueMessages(account: strongSelf.account, peerId: strongSelf.peerId, messages: [.message(text: "", media: file, replyToMessageId: nil)]).start() + enqueueMessages(account: strongSelf.account, peerId: strongSelf.peerId, messages: [.message(text: "", attributes: [], media: file, replyToMessageId: nil)]).start() } }, requestMessageActionCallback: { [weak self] messageId, data in if let strongSelf = self { - strongSelf.messageActionCallbackDisposable.set(requestMessageActionCallback(account: strongSelf.account, messageId: messageId, data: data).start()) + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { + return $0.updatedTitlePanelContext { + if !$0.contains(where: { + switch $0 { + case .requestInProgress: + return true + default: + return false + } + }) { + var updatedContexts = $0 + updatedContexts.append(.requestInProgress) + return updatedContexts + } + return $0 + } + }) + + strongSelf.messageActionCallbackDisposable.set((requestMessageActionCallback(account: strongSelf.account, messageId: messageId, data: data) |> afterDisposed { + Queue.mainQueue().async { + if let strongSelf = self { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { + return $0.updatedTitlePanelContext { + if let index = $0.index(where: { + switch $0 { + case .requestInProgress: + return true + default: + return false + } + }) { + var updatedContexts = $0 + updatedContexts.remove(at: index) + return updatedContexts + } + return $0 + } + }) + } + } + }).start(next: { result in + if let strongSelf = self { + switch result { + case .none: + break + case let .alert(text): + let message: Signal = .single(text) + let noMessage: Signal = .single(nil) + let delayedNoMessage: Signal = noMessage |> delay(1.0, queue: Queue.mainQueue()) + strongSelf.botCallbackAlertMessage.set(message |> then(delayedNoMessage)) + case let .url(url): + if let applicationContext = strongSelf.account.applicationContext as? TelegramApplicationContext { + applicationContext.openUrl(url) + } + } + } + })) } }, openUrl: { [weak self] url in if let strongSelf = self { if let applicationContext = strongSelf.account.applicationContext as? TelegramApplicationContext { + //openUrl(url, in: (strongSelf.view.window as! Window)) applicationContext.openUrl(url) } } @@ -220,10 +371,10 @@ public class ChatController: ViewController { if let strongSelf = self { strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({}) var postAsReply = false - if strongSelf.peerId.namespace == Namespaces.Peer.CloudChannel || strongSelf.peerId.namespace == Namespaces.Peer.CloudGroup { + if !command.contains("@") && (strongSelf.peerId.namespace == Namespaces.Peer.CloudChannel || strongSelf.peerId.namespace == Namespaces.Peer.CloudGroup) { postAsReply = true } - enqueueMessages(account: strongSelf.account, peerId: strongSelf.peerId, messages: [.message(text: command, media: nil, replyToMessageId: postAsReply ? messageId : nil)]).start() + enqueueMessages(account: strongSelf.account, peerId: strongSelf.peerId, messages: [.message(text: command, attributes: [], media: nil, replyToMessageId: postAsReply ? messageId : nil)]).start() } }) @@ -231,6 +382,30 @@ public class ChatController: ViewController { self.chatTitleView = ChatTitleView(frame: CGRect()) self.navigationItem.titleView = self.chatTitleView + self.chatTitleView?.pressed = { [weak self] in + if let strongSelf = self { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { + return $0.updatedTitlePanelContext { + if let index = $0.index(where: { + switch $0 { + case .chatInfo: + return true + default: + return false + } + }) { + var updatedContexts = $0 + updatedContexts.remove(at: index) + return updatedContexts + } else { + var updatedContexts = $0 + updatedContexts.insert(.chatInfo, at: 0) + return updatedContexts + } + } + }) + } + } let chatInfoButtonItem = UIBarButtonItem(customDisplayNode: ChatAvatarNavigationNode())! chatInfoButtonItem.target = self @@ -255,6 +430,53 @@ public class ChatController: ViewController { } } })) + + botCallbackAlertMessageDisposable = (self.botCallbackAlertMessage.get() + |> deliverOnMainQueue).start(next: { [weak self] message in + if let strongSelf = self { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { + return $0.updatedTitlePanelContext { + if let message = message { + if let index = $0.index(where: { + switch $0 { + case .toastAlert: + return true + default: + return false + } + }) { + if $0[index] != ChatTitlePanelContext.toastAlert(message) { + var updatedContexts = $0 + updatedContexts[index] = .toastAlert(message) + return updatedContexts + } else { + return $0 + } + } else { + var updatedContexts = $0 + updatedContexts.append(.toastAlert(message)) + return updatedContexts + } + } else { + if let index = $0.index(where: { + switch $0 { + case .toastAlert: + return true + default: + return false + } + }) { + var updatedContexts = $0 + updatedContexts.remove(at: index) + return updatedContexts + } else { + return $0 + } + } + } + }) + } + }) } required public init(coder aDecoder: NSCoder) { @@ -272,6 +494,8 @@ public class ChatController: ViewController { self.editMessageDisposable.dispose() self.enqueueMediaMessageDisposable.dispose() self.resolvePeerByNameDisposable?.dispose() + self.botCallbackAlertMessageDisposable?.dispose() + self.contextQueryState?.1.dispose() } var chatDisplayNode: ChatControllerNode { @@ -377,6 +601,8 @@ public class ChatController: ViewController { self.chatDisplayNode.displayAttachmentMenu = { [weak self] in if let strongSelf = self { if true { + strongSelf.chatDisplayNode.dismissInput() + let emptyController = LegacyEmptyController() let navigationController = makeLegacyNavigationController(rootController: emptyController) navigationController.setNavigationBarHidden(true, animated: false) @@ -392,13 +618,15 @@ public class ChatController: ViewController { } } }, openGallery: { - self?.presentMediaPicker() + self?.presentMediaPicker(fileMode: false) }, openCamera: { cameraView, menuController in if let strongSelf = self { presentedLegacyCamera(cameraView: cameraView, menuController: menuController, parentController: strongSelf, sendMessagesWithSignals: { signals in self?.enqueueMediaMessages(signals: signals) }) } + }, openFileGallery: { + self?.presentMediaPicker(fileMode: true) }, sendMessagesWithSignals: { [weak self] signals in self?.enqueueMediaMessages(signals: signals) }) @@ -423,13 +651,6 @@ public class ChatController: ViewController { } } - //controller presentInViewController:self sourceView:_inputTextPanel.attachButton animated:true]; - - return - } - - if true { - strongSelf.presentMediaPicker() return } @@ -445,10 +666,10 @@ public class ChatController: ViewController { if false { let media = TelegramMediaImage(imageId: MediaId(namespace: Namespaces.Media.LocalImage, id: randomId), representations: [TelegramMediaImageRepresentation(dimensions: scaledSize, resource: resource)]) strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({}) - enqueueMessages(account: strongSelf.account, peerId: strongSelf.peerId, messages: [.message(text: "", media: media, replyToMessageId: nil)]).start() + enqueueMessages(account: strongSelf.account, peerId: strongSelf.peerId, messages: [.message(text: "", attributes: [], media: media, replyToMessageId: nil)]).start() } else { let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: randomId), resource: resource, previewRepresentations: [], mimeType: "image/jpeg", size: 0, attributes: [.FileName(fileName: "image.jpeg"), .ImageSize(size: scaledSize)]) - enqueueMessages(account: strongSelf.account, peerId: strongSelf.peerId, messages: [.message(text: "", media: media, replyToMessageId: nil)]).start() + enqueueMessages(account: strongSelf.account, peerId: strongSelf.peerId, messages: [.message(text: "", attributes: [], media: media, replyToMessageId: nil)]).start() } } } @@ -551,9 +772,9 @@ public class ChatController: ViewController { strongSelf.present(controller, in: .window) } } - }, updateTextInputState: { [weak self] textInputState in + }, updateTextInputState: { [weak self] f in if let strongSelf = self { - strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withUpdatedEffectiveInputState(textInputState) } }) + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withUpdatedEffectiveInputState(f($0.effectiveInputState)) } }) } }, updateInputMode: { [weak self] f in if let strongSelf = self { @@ -571,6 +792,16 @@ public class ChatController: ViewController { } })) } + }, beginMessageSearch: { [weak self] in + if let strongSelf = self { + + } + }, openPeerInfo: { [weak self] in + self?.navigationButtonAction(.openChatInfo) + }, togglePeerNotifications: { + + }, sendContextResult: { [weak self] results, result in + self?.enqueueChatContextResult(results, result) }, statuses: ChatPanelInterfaceInteractionStatuses(editingMessage: self.editingMessage.get())) self.interfaceInteraction = interfaceInteraction @@ -610,6 +841,29 @@ public class ChatController: ViewController { }).start() } + override public func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + self.updateChatPresentationInterfaceState(animated: false, interactive: false, { + $0.updatedTitlePanelContext { + if let index = $0.index(where: { + switch $0 { + case .chatInfo: + return true + default: + return false + } + }) { + var updatedContexts = $0 + updatedContexts.remove(at: index) + return updatedContexts + } else { + return $0 + } + } + }) + } + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) @@ -622,15 +876,40 @@ public class ChatController: ViewController { func updateChatPresentationInterfaceState(animated: Bool = true, interactive: Bool, _ f: (ChatPresentationInterfaceState) -> ChatPresentationInterfaceState) { let temporaryChatPresentationInterfaceState = f(self.presentationInterfaceState) - let inputContext = inputContextForChatPresentationIntefaceState(temporaryChatPresentationInterfaceState, account: self.account) + let inputContextQuery = inputContextQueryForChatPresentationIntefaceState(temporaryChatPresentationInterfaceState, account: self.account) let inputTextPanelState = inputTextPanelStateForChatPresentationInterfaceState(temporaryChatPresentationInterfaceState, account: self.account) - let updatedChatPresentationInterfaceState = temporaryChatPresentationInterfaceState.updatedInputContext({ _ in return inputContext }).updatedInputTextPanelState({ _ in return inputTextPanelState }) + var updatedChatPresentationInterfaceState = temporaryChatPresentationInterfaceState.updatedInputTextPanelState({ _ in return inputTextPanelState }) + if let (updatedContextQueryState, updatedContextQuerySignal) = contextQueryResultStateForChatInterfacePresentationState(updatedChatPresentationInterfaceState, account: self.account, currentQuery: self.contextQueryState?.0) { + self.contextQueryState?.1.dispose() + var inScope = true + var inScopeResult: ((ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?)? + self.contextQueryState = (updatedContextQueryState, (updatedContextQuerySignal |> deliverOnMainQueue).start(next: { [weak self] result in + if let strongSelf = self { + if Thread.isMainThread && inScope { + inScope = false + inScopeResult = result + } else { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { + $0.updatedInputQueryResult { previousResult in + return result(previousResult) + } + }) + } + } + })) + inScope = false + if let inScopeResult = inScopeResult { + updatedChatPresentationInterfaceState = updatedChatPresentationInterfaceState.updatedInputQueryResult { previousResult in + return inScopeResult(previousResult) + } + } + } + + self.presentationInterfaceState = updatedChatPresentationInterfaceState if self.isNodeLoaded { self.chatDisplayNode.updateChatPresentationInterfaceState(updatedChatPresentationInterfaceState, animated: animated, interactive: interactive) } - self.presentationInterfaceState = updatedChatPresentationInterfaceState - self.chatInterfaceStatePromise.set(.single(updatedChatPresentationInterfaceState.interfaceState)) if let button = leftNavigationButtonForChatInterfaceState(updatedChatPresentationInterfaceState.interfaceState, currentButton: self.leftNavigationButton, target: self, selector: #selector(self.leftNavigationButtonAction)) { self.navigationItem.setLeftBarButton(button.buttonItem, animated: true) @@ -702,8 +981,8 @@ public class ChatController: ViewController { } } - private func presentMediaPicker() { - legacyAssetPicker().start(next: { [weak self] generator in + private func presentMediaPicker(fileMode: Bool) { + legacyAssetPicker(fileMode: fileMode).start(next: { [weak self] generator in if let strongSelf = self { var presentOverlayController: ((UIViewController) -> (() -> Void))? let controller = generator({ controller in @@ -748,7 +1027,7 @@ public class ChatController: ViewController { let replyMessageId = strongSelf.presentationInterfaceState.interfaceState.replyMessageId strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({ if let strongSelf = self { - strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: false, { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { $0.updatedInterfaceState { $0.withUpdatedReplyMessageId(nil) } }) } @@ -757,4 +1036,18 @@ public class ChatController: ViewController { } })) } + + private func enqueueChatContextResult(_ results: ChatContextResultCollection, _ result: ChatContextResult) { + if let message = outgoingMessageWithChatContextResult(results, result) { + let replyMessageId = self.presentationInterfaceState.interfaceState.replyMessageId + self.chatDisplayNode.setupSendActionOnViewUpdate({ [weak self] in + if let strongSelf = self { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { + $0.updatedInterfaceState { $0.withUpdatedReplyMessageId(nil).withUpdatedComposeInputState(ChatTextInputState(inputText: "")) } + }) + } + }) + enqueueMessages(account: self.account, peerId: self.peerId, messages: [message.withUpdatedReplyToMessageId(replyMessageId)]).start() + } + } } diff --git a/TelegramUI/ChatControllerInteraction.swift b/TelegramUI/ChatControllerInteraction.swift index 6e89c0cdc5..c31c11d247 100644 --- a/TelegramUI/ChatControllerInteraction.swift +++ b/TelegramUI/ChatControllerInteraction.swift @@ -4,13 +4,13 @@ import AsyncDisplayKit import TelegramCore public enum ChatControllerInteractionNavigateToPeer { - case chat + case chat(textInputState: ChatTextInputState?) case info } public final class ChatControllerInteraction { let openMessage: (MessageId) -> Void - let openPeer: (PeerId, ChatControllerInteractionNavigateToPeer) -> Void + let openPeer: (PeerId?, ChatControllerInteractionNavigateToPeer) -> Void let openPeerMention: (String) -> Void let openMessageContextMenu: (MessageId, ASDisplayNode, CGRect) -> Void let navigateToMessage: (MessageId, MessageId) -> Void @@ -26,7 +26,7 @@ public final class ChatControllerInteraction { let shareAccountContact: () -> Void let sendBotCommand: (MessageId, String) -> Void - public init(openMessage: @escaping (MessageId) -> Void, openPeer: @escaping (PeerId, ChatControllerInteractionNavigateToPeer) -> Void, openPeerMention: @escaping (String) -> Void, openMessageContextMenu: @escaping (MessageId, ASDisplayNode, CGRect) -> Void, navigateToMessage: @escaping (MessageId, MessageId) -> Void, clickThroughMessage: @escaping () -> Void, toggleMessageSelection: @escaping (MessageId) -> Void, sendMessage: @escaping (String) -> Void, sendSticker: @escaping (TelegramMediaFile) -> Void, requestMessageActionCallback: @escaping (MessageId, MemoryBuffer?) -> Void, openUrl: @escaping (String) -> Void, shareCurrentLocation: @escaping () -> Void, shareAccountContact: @escaping () -> Void, sendBotCommand: @escaping (MessageId, String) -> Void) { + public init(openMessage: @escaping (MessageId) -> Void, openPeer: @escaping (PeerId?, ChatControllerInteractionNavigateToPeer) -> Void, openPeerMention: @escaping (String) -> Void, openMessageContextMenu: @escaping (MessageId, ASDisplayNode, CGRect) -> Void, navigateToMessage: @escaping (MessageId, MessageId) -> Void, clickThroughMessage: @escaping () -> Void, toggleMessageSelection: @escaping (MessageId) -> Void, sendMessage: @escaping (String) -> Void, sendSticker: @escaping (TelegramMediaFile) -> Void, requestMessageActionCallback: @escaping (MessageId, MemoryBuffer?) -> Void, openUrl: @escaping (String) -> Void, shareCurrentLocation: @escaping () -> Void, shareAccountContact: @escaping () -> Void, sendBotCommand: @escaping (MessageId, String) -> Void) { self.openMessage = openMessage self.openPeer = openPeer self.openPeerMention = openPeerMention diff --git a/TelegramUI/ChatControllerNode.swift b/TelegramUI/ChatControllerNode.swift index 6cf1e42a04..5b0837dc30 100644 --- a/TelegramUI/ChatControllerNode.swift +++ b/TelegramUI/ChatControllerNode.swift @@ -23,6 +23,9 @@ class ChatControllerNode: ASDisplayNode { private let inputPanelBackgroundNode: ASDisplayNode private let inputPanelBackgroundSeparatorNode: ASDisplayNode + private let titleAccessoryPanelContainer: ChatControllerTitlePanelNodeContainer + private var titleAccessoryPanelNode: ChatTitleAccessoryPanelNode? + private var inputPanelNode: ChatInputPanelNode? private var accessoryPanelNode: AccessoryPanelNode? private var inputContextPanelNode: ChatInputContextPanelNode? @@ -61,6 +64,9 @@ class ChatControllerNode: ASDisplayNode { self.backgroundNode.displaysAsynchronously = false self.backgroundNode.clipsToBounds = true + self.titleAccessoryPanelContainer = ChatControllerTitlePanelNodeContainer() + self.titleAccessoryPanelContainer.clipsToBounds = true + self.historyNode = ChatHistoryListNode(account: account, peerId: peerId, tagMask: nil, messageId: messageId, controllerInteraction: controllerInteraction) self.inputPanelBackgroundNode = ASDisplayNode() @@ -84,6 +90,8 @@ class ChatControllerNode: ASDisplayNode { self.addSubnode(self.historyNode) + self.addSubnode(self.titleAccessoryPanelContainer) + self.addSubnode(self.inputPanelBackgroundNode) self.addSubnode(self.inputPanelBackgroundSeparatorNode) @@ -129,7 +137,7 @@ class ChatControllerNode: ASDisplayNode { var messages: [EnqueueMessage] = [] if !text.isEmpty { - messages.append(.message(text: text, media: nil, replyToMessageId: strongSelf.chatPresentationInterfaceState.interfaceState.replyMessageId)) + messages.append(.message(text: text, attributes: [], media: nil, replyToMessageId: strongSelf.chatPresentationInterfaceState.interfaceState.replyMessageId)) } if let forwardMessageIds = strongSelf.chatPresentationInterfaceState.interfaceState.forwardMessageIds { for id in forwardMessageIds { @@ -164,6 +172,20 @@ class ChatControllerNode: ASDisplayNode { } self.containerLayoutAndNavigationBarHeight = (layout, navigationBarHeight) + var dismissedTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode? + var immediatelyLayoutTitleAccessoryPanelNodeAndAnimateAppearance = false + if let titleAccessoryPanelNode = titlePanelForChatPresentationIntefaceState(self.chatPresentationInterfaceState, account: self.account, currentPanel: self.titleAccessoryPanelNode, interfaceInteraction: self.interfaceInteraction) { + if self.titleAccessoryPanelNode != titleAccessoryPanelNode { + dismissedTitleAccessoryPanelNode = self.titleAccessoryPanelNode + self.titleAccessoryPanelNode = titleAccessoryPanelNode + immediatelyLayoutTitleAccessoryPanelNodeAndAnimateAppearance = true + self.titleAccessoryPanelContainer.addSubnode(titleAccessoryPanelNode) + } + } else if let titleAccessoryPanelNode = self.titleAccessoryPanelNode { + dismissedTitleAccessoryPanelNode = titleAccessoryPanelNode + self.titleAccessoryPanelNode = nil + } + var dismissedInputNode: ChatInputNode? var immediatelyLayoutInputNodeAndAnimateAppearance = false var inputNodeHeight: CGFloat? @@ -199,6 +221,15 @@ class ChatControllerNode: ASDisplayNode { } insets.top += navigationBarHeight + transition.updateFrame(node: self.titleAccessoryPanelContainer, frame: CGRect(origin: CGPoint(x: 0.0, y: insets.top), size: CGSize(width: layout.size.width, height: 44.0))) + + var titleAccessoryPanelFrame: CGRect? + if let titleAccessoryPanelNode = self.titleAccessoryPanelNode { + let panelHeight = titleAccessoryPanelNode.updateLayout(width: layout.size.width, transition: immediatelyLayoutTitleAccessoryPanelNodeAndAnimateAppearance ? .immediate : transition, interfaceState: self.chatPresentationInterfaceState) + titleAccessoryPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: layout.size.width, height: panelHeight)) + insets.top += panelHeight + } + var duration: Double = 0.0 var curve: UInt = 0 var animated = true @@ -329,6 +360,13 @@ class ChatControllerNode: ASDisplayNode { transition.updateFrame(node: self.inputPanelBackgroundSeparatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: inputBackgroundFrame.origin.y - UIScreenPixel), size: CGSize(width: inputBackgroundFrame.size.width, height: UIScreenPixel))) transition.updateFrame(node: self.navigateToLatestButton, frame: navigateToLatestButtonFrame) + if let titleAccessoryPanelNode = self.titleAccessoryPanelNode, let titleAccessoryPanelFrame = titleAccessoryPanelFrame, !titleAccessoryPanelNode.frame.equalTo(titleAccessoryPanelFrame) { + if immediatelyLayoutTitleAccessoryPanelNodeAndAnimateAppearance { + titleAccessoryPanelNode.frame = titleAccessoryPanelFrame.offsetBy(dx: 0.0, dy: -titleAccessoryPanelFrame.size.height) + } + transition.updateFrame(node: titleAccessoryPanelNode, frame: titleAccessoryPanelFrame) + } + if let inputPanelNode = self.inputPanelNode, let inputPanelFrame = inputPanelFrame, !inputPanelNode.frame.equalTo(inputPanelFrame) { if immediatelyLayoutInputPanelAndAnimateAppearance { inputPanelNode.frame = inputPanelFrame.offsetBy(dx: 0.0, dy: inputPanelFrame.size.height) @@ -351,16 +389,15 @@ class ChatControllerNode: ASDisplayNode { transition.updateAlpha(node: accessoryPanelNode, alpha: 1.0) } - let inputContextPanelsFrame = CGRect(origin: CGPoint(x: 0.0, y: insets.top), size: CGSize(width: layout.size.width, height: max(0.0, layout.size.height - insets.bottom - inputPanelsHeight - insets.top))) + let inputContextPanelsFrame = CGRect(origin: CGPoint(x: 0.0, y: insets.top), size: CGSize(width: layout.size.width, height: max(0.0, layout.size.height - insets.bottom - inputPanelsHeight - insets.top - UIScreenPixel))) if let inputContextPanelNode = self.inputContextPanelNode { if immediatelyLayoutInputContextPanelAndAnimateAppearance { inputContextPanelNode.frame = inputContextPanelsFrame - inputContextPanelNode.updateFrames(transition: .immediate) - inputContextPanelNode.animateIn() + inputContextPanelNode.updateLayout(size: inputContextPanelsFrame.size, transition: .immediate, interfaceState: self.chatPresentationInterfaceState) } else if !inputContextPanelNode.frame.equalTo(inputContextPanelsFrame) { transition.updateFrame(node: inputContextPanelNode, frame: inputContextPanelsFrame) - inputContextPanelNode.updateFrames(transition: transition) + inputContextPanelNode.updateLayout(size: inputContextPanelsFrame.size, transition: transition, interfaceState: self.chatPresentationInterfaceState) } } @@ -377,6 +414,14 @@ class ChatControllerNode: ASDisplayNode { } } + if let dismissedTitleAccessoryPanelNode = dismissedTitleAccessoryPanelNode { + var dismissedPanelFrame = dismissedTitleAccessoryPanelNode.frame + dismissedPanelFrame.origin.y = -dismissedPanelFrame.size.height + transition.updateFrame(node: dismissedTitleAccessoryPanelNode, frame: dismissedPanelFrame, completion: { [weak dismissedTitleAccessoryPanelNode] _ in + dismissedTitleAccessoryPanelNode?.removeFromSupernode() + }) + } + if let dismissedInputPanelNode = dismissedInputPanelNode { var frameCompleted = false var alphaCompleted = false @@ -477,10 +522,17 @@ class ChatControllerNode: ASDisplayNode { self.chatPresentationInterfaceState = chatPresentationInterfaceState let keepSendButtonEnabled = chatPresentationInterfaceState.interfaceState.forwardMessageIds != nil || chatPresentationInterfaceState.interfaceState.editMessage != nil + var extendedSearchLayout = false + if let inputQueryResult = chatPresentationInterfaceState.inputQueryResult { + if case let .contextRequestResult(peer, _) = inputQueryResult { + extendedSearchLayout = true + } + } + if let textInputPanelNode = self.textInputPanelNode, updateInputTextState { - textInputPanelNode.updateInputTextState(chatPresentationInterfaceState.interfaceState.effectiveInputState, keepSendButtonEnabled: keepSendButtonEnabled, animated: animated) + textInputPanelNode.updateInputTextState(chatPresentationInterfaceState.interfaceState.effectiveInputState, keepSendButtonEnabled: keepSendButtonEnabled, extendedSearchLayout: extendedSearchLayout, animated: animated) } else { - textInputPanelNode?.updateKeepSendButtonEnabled(keepSendButtonEnabled: keepSendButtonEnabled, animated: animated) + textInputPanelNode?.updateKeepSendButtonEnabled(keepSendButtonEnabled: keepSendButtonEnabled, extendedSearchLayout: extendedSearchLayout, animated: animated) } let layoutTransition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.4, curve: .spring) : .immediate diff --git a/TelegramUI/ChatControllerTitlePanelNodeContainer.swift b/TelegramUI/ChatControllerTitlePanelNodeContainer.swift new file mode 100644 index 0000000000..c588f5a525 --- /dev/null +++ b/TelegramUI/ChatControllerTitlePanelNodeContainer.swift @@ -0,0 +1,20 @@ +import Foundation +import AsyncDisplayKit + +final class ChatControllerTitlePanelNodeContainer: ASDisplayNode { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if self.bounds.contains(point) { + var foundHit = false + for subnode in self.subnodes { + if subnode.frame.contains(point) { + foundHit = true + break + } + } + if !foundHit { + return nil + } + } + return super.hitTest(point, with: event) + } +} diff --git a/TelegramUI/ChatHistoryListNode.swift b/TelegramUI/ChatHistoryListNode.swift index 0c41bcdd31..df72be0fc7 100644 --- a/TelegramUI/ChatHistoryListNode.swift +++ b/TelegramUI/ChatHistoryListNode.swift @@ -95,7 +95,14 @@ private func mappedInsertEntries(account: Account, peerId: PeerId, controllerInt } 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(index: entry.entry.index), directionHint: entry.directionHint) + let item: ListViewItem + switch mode { + case .bubbles: + item = ChatHoleItem(index: entry.entry.index) + case .list: + item = ListMessageHoleItem() + } + return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: item, directionHint: entry.directionHint) case .UnreadEntry: return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatUnreadItem(index: entry.entry.index), directionHint: entry.directionHint) } @@ -115,7 +122,14 @@ private func mappedUpdateEntries(account: Account, peerId: PeerId, controllerInt } 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(index: entry.entry.index), directionHint: entry.directionHint) + let item: ListViewItem + switch mode { + case .bubbles: + item = ChatHoleItem(index: entry.entry.index) + case .list: + item = ListMessageHoleItem() + } + return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: item, directionHint: entry.directionHint) case .UnreadEntry: return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatUnreadItem(index: entry.entry.index), directionHint: entry.directionHint) } @@ -180,6 +194,8 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { super.init() + //self.stackFromBottom = true + //self.debugInfo = true self.preloadPages = false diff --git a/TelegramUI/ChatInfoTitlePanelNode.swift b/TelegramUI/ChatInfoTitlePanelNode.swift new file mode 100644 index 0000000000..f36bd11e75 --- /dev/null +++ b/TelegramUI/ChatInfoTitlePanelNode.swift @@ -0,0 +1,122 @@ +import Foundation +import Display +import AsyncDisplayKit +import Postbox +import TelegramCore + +private enum ChatInfoTitleButton { + case search + case info + case mute + case unmute + + var title: String { + switch self { + case .search: + return "Search" + case .info: + return "Info" + case .mute: + return "Mute" + case .unmute: + return "Unmute" + } + } +} + +private func peerButtons(_ peer: Peer) -> [ChatInfoTitleButton] { + if let _ = peer as? TelegramUser { + return [.search, .info] + } else { + return [.search, .mute] + } +} + +final class ChatInfoTitlePanelNode: ChatTitleAccessoryPanelNode { + private let separatorNode: ASDisplayNode + + private var buttons: [(ChatInfoTitleButton, UIButton)] = [] + + override init() { + self.separatorNode = ASDisplayNode() + self.separatorNode.backgroundColor = UIColor(red: 0.6953125, green: 0.6953125, blue: 0.6953125, alpha: 1.0) + self.separatorNode.isLayerBacked = true + + super.init() + + self.backgroundColor = UIColor(0xF5F6F8) + + self.addSubnode(self.separatorNode) + } + + override func updateLayout(width: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> CGFloat { + let panelHeight: CGFloat = 44.0 + + let updatedButtons: [ChatInfoTitleButton] + if let peer = interfaceState.peer { + updatedButtons = peerButtons(peer) + } else { + updatedButtons = [] + } + + var buttonsUpdated = false + if self.buttons.count != updatedButtons.count { + buttonsUpdated = true + } else { + for i in 0 ..< updatedButtons.count { + if self.buttons[i].0 != updatedButtons[i] { + buttonsUpdated = true + break + } + } + } + + if buttonsUpdated { + for (_, view) in self.buttons { + view.removeFromSuperview() + } + self.buttons.removeAll() + for button in updatedButtons { + let view = UIButton() + view.setTitle(button.title, for: []) + view.titleLabel?.font = Font.regular(17.0) + view.setTitleColor(UIColor(0x007ee5), for: []) + view.setTitleColor(UIColor(0x007ee5).withAlphaComponent(0.7), for: [.highlighted]) + view.addTarget(self, action: #selector(self.buttonPressed(_:)), for: [.touchUpInside]) + self.view.addSubview(view) + self.buttons.append((button, view)) + } + } + + if !self.buttons.isEmpty { + let buttonWidth = floor(width / CGFloat(self.buttons.count)) + var nextButtonOrigin: CGFloat = 0.0 + for (_, view) in self.buttons { + view.frame = CGRect(origin: CGPoint(x: nextButtonOrigin, y: 0.0), size: CGSize(width: buttonWidth, height: panelHeight)) + nextButtonOrigin += buttonWidth + } + } + + transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: panelHeight - UIScreenPixel), size: CGSize(width: width, height: UIScreenPixel))) + + return panelHeight + } + + @objc func buttonPressed(_ view: UIButton) { + for (button, buttonView) in self.buttons { + if buttonView === view { + switch button { + case .info: + self.interfaceInteraction?.openPeerInfo() + case .mute: + self.interfaceInteraction?.togglePeerNotifications() + case .unmute: + self.interfaceInteraction?.togglePeerNotifications() + case .search: + self.interfaceInteraction?.beginMessageSearch() + } + break + } + } + } +} diff --git a/TelegramUI/ChatInputContextPanelNode.swift b/TelegramUI/ChatInputContextPanelNode.swift index 9e42ca349f..62d06a5d2b 100644 --- a/TelegramUI/ChatInputContextPanelNode.swift +++ b/TelegramUI/ChatInputContextPanelNode.swift @@ -1,15 +1,19 @@ import Foundation import AsyncDisplayKit import Display +import TelegramCore class ChatInputContextPanelNode: ASDisplayNode { + let account: Account var interfaceInteraction: ChatPanelInterfaceInteraction? - func updateFrames(transition: ContainedViewLayoutTransition) { + init(account: Account) { + self.account = account + + super.init() } - func animateIn() { - + func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { } func animateOut(completion: @escaping () -> Void) { diff --git a/TelegramUI/ChatInterfaceInputContextPanels.swift b/TelegramUI/ChatInterfaceInputContextPanels.swift index 0707a836e7..c0b15e9f18 100644 --- a/TelegramUI/ChatInterfaceInputContextPanels.swift +++ b/TelegramUI/ChatInterfaceInputContextPanels.swift @@ -2,28 +2,50 @@ import Foundation import TelegramCore func inputContextPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, account: Account, currentPanel: ChatInputContextPanelNode?, interfaceInteraction: ChatPanelInterfaceInteraction?) -> ChatInputContextPanelNode? { - guard let inputContext = chatPresentationInterfaceState.inputContext, let peer = chatPresentationInterfaceState.peer else { + guard let inputQueryResult = chatPresentationInterfaceState.inputQueryResult, let peer = chatPresentationInterfaceState.peer else { return nil } - switch inputContext { - case .hashtag: + switch inputQueryResult { + case let .hashtags(results): if let currentPanel = currentPanel as? HashtagChatInputContextPanelNode { + currentPanel.updateResults(results) return currentPanel } else { - let panel = HashtagChatInputContextPanelNode() + let panel = HashtagChatInputContextPanelNode(account: account) panel.interfaceInteraction = interfaceInteraction + panel.updateResults(results) return panel } - case .mention: + case let .mentions(peers): if let currentPanel = currentPanel as? MentionChatInputContextPanelNode { + currentPanel.updateResults(peers) return currentPanel } else { - let panel = MentionChatInputContextPanelNode() + let panel = MentionChatInputContextPanelNode(account: account) panel.interfaceInteraction = interfaceInteraction - panel.setup(account: account, peerId: peer.id, query: "") + panel.updateResults(peers) return panel } + case let .commands(peersAndCommands): + return nil + case let .contextRequestResult(peer, results): + if let results = results, (!results.results.isEmpty || results.switchPeer != nil) { + switch results.presentation { + case .list, .media: + if let currentPanel = currentPanel as? VerticalListContextResultsChatInputContextPanelNode { + currentPanel.updateResults(results) + return currentPanel + } else { + let panel = VerticalListContextResultsChatInputContextPanelNode(account: account) + panel.interfaceInteraction = interfaceInteraction + panel.updateResults(results) + return panel + } + } + } else { + return nil + } } return nil diff --git a/TelegramUI/ChatInterfaceInputContexts.swift b/TelegramUI/ChatInterfaceInputContexts.swift index dcfca65d80..a3dfdc18d8 100644 --- a/TelegramUI/ChatInterfaceInputContexts.swift +++ b/TelegramUI/ChatInterfaceInputContexts.swift @@ -2,15 +2,133 @@ import Foundation import TelegramCore import Postbox -func inputContextForChatPresentationIntefaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, account: Account) -> ChatPresentationInputContext? { - if let _ = chatPresentationInterfaceState.interfaceState.editMessage { +struct PossibleContextQueryTypes: OptionSet { + var rawValue: Int32 + + init() { + self.rawValue = 0 + } + + init(rawValue: Int32) { + self.rawValue = rawValue + } + + static let hashtag = PossibleContextQueryTypes(rawValue: (1 << 0)) + static let mention = PossibleContextQueryTypes(rawValue: (1 << 1)) + static let contextRequest = PossibleContextQueryTypes(rawValue: (1 << 2)) +} + +private func makeScalar(_ c: Character) -> Character { + return c + //return c.utf16[c.utf16.startIndex] +} + +private let spaceScalar = makeScalar(" ") +private let newlineScalar = makeScalar("\n") +private let hashScalar = makeScalar("#") +private let atScalar = makeScalar("@") +private let alphanumerics = CharacterSet.alphanumerics + +func textInputStateContextQueryRangeAndType(_ inputState: ChatTextInputState) -> (Range, PossibleContextQueryTypes, Range?)? { + let inputText = inputState.inputText + if !inputText.isEmpty { + if inputText.hasPrefix("@") && inputText != "@" { + let startIndex = inputText.index(after: inputText.startIndex) + var index = startIndex + var contextAddressRange: Range? + + while true { + if index == inputText.endIndex { + break + } + let c = inputText[index] + + if c == " " { + if index != startIndex { + contextAddressRange = startIndex ..< index + index = inputText.index(after: index) + } + break + } else { + if !((c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_") { + break + } + } + + if index == inputText.endIndex { + break + } else { + index = inputText.index(after: index) + } + } + + if let contextAddressRange = contextAddressRange { + return (contextAddressRange, [.contextRequest], index ..< inputText.endIndex) + } + } + + let maxUtfIndex = inputText.utf16.index(inputText.utf16.startIndex, offsetBy: inputState.selectionRange.lowerBound) + guard let maxIndex = maxUtfIndex.samePosition(in: inputText) else { + return nil + } + if maxIndex == inputText.startIndex { + return nil + } + var index = inputText.index(before: maxIndex) + + var possibleQueryRange: Range? + + var possibleTypes = PossibleContextQueryTypes([.mention]) + var definedType = false + + while true { + let c = inputText[index] + + if c == spaceScalar || c == newlineScalar { + possibleTypes = [] + } else if c == hashScalar { + possibleTypes = possibleTypes.intersection([.hashtag]) + definedType = true + index = inputText.index(after: index) + possibleQueryRange = index ..< maxIndex + break + } else if c == atScalar { + possibleTypes = possibleTypes.intersection([.mention]) + definedType = true + index = inputText.index(after: index) + possibleQueryRange = index ..< maxIndex + break + } + + if index == inputText.startIndex { + break + } else { + index = inputText.index(before: index) + possibleQueryRange = index ..< maxIndex + } + } + + if let possibleQueryRange = possibleQueryRange, definedType && !possibleTypes.isEmpty { + return (possibleQueryRange, possibleTypes, nil) + } + } + return nil +} + +func inputContextQueryForChatPresentationIntefaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, account: Account) -> ChatPresentationInputQuery? { + let inputState = chatPresentationInterfaceState.interfaceState.effectiveInputState + if let (possibleQueryRange, possibleTypes, additionalStringRange) = textInputStateContextQueryRangeAndType(inputState) { + let query = inputState.inputText.substring(with: possibleQueryRange) + if possibleTypes == [.hashtag] { + return .hashtag(query) + } else if possibleTypes == [.mention] { + return .mention(query) + } else if possibleTypes == [.contextRequest], let additionalStringRange = additionalStringRange { + let additionalString = inputState.inputText.substring(with: additionalStringRange) + return .contextRequest(addressName: query, query: additionalString) + } return nil } else { - if chatPresentationInterfaceState.interfaceState.composeInputState.inputText == "#" { - return .hashtag - } else if chatPresentationInterfaceState.interfaceState.composeInputState.inputText == "@" { - return .mention - } return nil } } diff --git a/TelegramUI/ChatInterfaceState.swift b/TelegramUI/ChatInterfaceState.swift index f6f64b07a5..5957254c3e 100644 --- a/TelegramUI/ChatInterfaceState.swift +++ b/TelegramUI/ChatInterfaceState.swift @@ -27,11 +27,11 @@ struct ChatInterfaceSelectionState: Coding, Equatable { } } -struct ChatTextInputState: Coding, Equatable { +public struct ChatTextInputState: Coding, Equatable { let inputText: String let selectionRange: Range - static func ==(lhs: ChatTextInputState, rhs: ChatTextInputState) -> Bool { + public static func ==(lhs: ChatTextInputState, rhs: ChatTextInputState) -> Bool { return lhs.inputText == rhs.inputText && lhs.selectionRange == rhs.selectionRange } @@ -51,12 +51,12 @@ struct ChatTextInputState: Coding, Equatable { self.selectionRange = length ..< length } - init(decoder: Decoder) { + public init(decoder: Decoder) { self.inputText = decoder.decodeStringForKey("t") self.selectionRange = Int(decoder.decodeInt32ForKey("s0")) ..< Int(decoder.decodeInt32ForKey("s1")) } - func encode(_ encoder: Encoder) { + public func encode(_ encoder: Encoder) { encoder.encodeString(self.inputText, forKey: "t") encoder.encodeInt32(Int32(self.selectionRange.lowerBound), forKey: "s0") encoder.encodeInt32(Int32(self.selectionRange.upperBound), forKey: "s1") @@ -249,6 +249,12 @@ final class ChatInterfaceState: PeerChatInterfaceState, Equatable { return lhs.composeInputState == rhs.composeInputState && lhs.replyMessageId == rhs.replyMessageId && lhs.selectionState == rhs.selectionState && lhs.editMessage == rhs.editMessage } + func withUpdatedComposeInputState(_ inputState: ChatTextInputState) -> ChatInterfaceState { + var updatedComposeInputState = inputState + + return ChatInterfaceState(timestamp: self.timestamp, composeInputState: updatedComposeInputState, replyMessageId: self.replyMessageId, forwardMessageIds: self.forwardMessageIds, editMessage: self.editMessage, selectionState: self.selectionState) + } + func withUpdatedEffectiveInputState(_ inputState: ChatTextInputState) -> ChatInterfaceState { var updatedEditMessage = self.editMessage var updatedComposeInputState = self.composeInputState diff --git a/TelegramUI/ChatInterfaceStateContextQueries.swift b/TelegramUI/ChatInterfaceStateContextQueries.swift new file mode 100644 index 0000000000..e73acf966a --- /dev/null +++ b/TelegramUI/ChatInterfaceStateContextQueries.swift @@ -0,0 +1,136 @@ +import Foundation +import SwiftSignalKit +import TelegramCore +import Postbox + +func contextQueryResultStateForChatInterfacePresentationState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, account: Account, currentQuery: ChatPresentationInputQuery?) -> (ChatPresentationInputQuery?, Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, NoError>)? { + if let inputQuery = inputContextQueryForChatPresentationIntefaceState(chatPresentationInterfaceState, account: account) { + if inputQuery == currentQuery { + return nil + } else { + switch inputQuery { + case let .hashtag(query): + /*var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, NoError> = .complete() + if let currentQuery = currentQuery { + switch currentQuery { + case .hashtag: + break + default: + signal = .single({ _ in return nil }) + } + } + + let hashtags: Signal = .single(.hashtags((0 ..< 3).map { "tag\($0)" })) + + return (inputQuery, signal |> then(hashtags))*/ + return (nil, .single({ _ in return nil })) + case let .mention(query): + let normalizedQuery = query.lowercased() + + if let peer = chatPresentationInterfaceState.peer { + var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, NoError> = .complete() + if let currentQuery = currentQuery { + switch currentQuery { + case .mention: + break + default: + signal = .single({ _ in return nil }) + } + } + + let participants = peerParticipants(account: account, id: peer.id) + |> map { peers -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in + let filteredPeers = peers.filter { peer in + if peer.indexName.match(query: normalizedQuery) { + return true + } + if let addressName = peer.addressName, addressName.lowercased().hasPrefix(normalizedQuery) { + return true + } + return false + } + let sortedPeers = filteredPeers.sorted(by: { lhs, rhs in + let result = lhs.indexName.indexName(.lastNameFirst).compare(rhs.indexName.indexName(.lastNameFirst)) + return result == .orderedAscending + }) + return { _ in return .mentions(sortedPeers) } + } + + return (inputQuery, signal |> then(participants)) + } else { + return (nil, .single({ _ in return nil })) + } + case let .command(query): + return (nil, .single({ _ in return nil })) + case let .contextRequest(addressName, query): + guard let chatPeer = chatPresentationInterfaceState.peer else { + return (nil, .single({ _ in return nil })) + } + + var delayRequest = true + var signal: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, NoError> = .complete() + if let currentQuery = currentQuery { + switch currentQuery { + case let .contextRequest(currentAddressName, currentContextQuery) where currentAddressName == addressName: + if currentContextQuery.isEmpty != query.isEmpty { + delayRequest = false + } + default: + delayRequest = false + signal = .single({ _ in return nil }) + } + } + + let contextBot = resolvePeerByName(account: account, name: addressName) + |> mapToSignal { peerId -> Signal in + if let peerId = peerId { + return account.postbox.loadedPeerWithId(peerId) + |> map { peer -> Peer? in + return peer + } + |> take(1) + } else { + return .single(nil) + } + } + |> mapToSignal { peer -> Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, NoError> in + if let user = peer as? TelegramUser, let botInfo = user.botInfo, let _ = botInfo.inlinePlaceholder { + let contextResults = requestChatContextResults(account: account, botId: user.id, peerId: chatPeer.id, query: query, offset: "") + |> map { results -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in + return { _ in + return .contextRequestResult(user, results) + } + } + + let botResult: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, NoError> = .single({ previousResult in + var passthroughPreviousResult: ChatContextResultCollection? + if let previousResult = previousResult { + if case let .contextRequestResult(previousUser, previousResults) = previousResult { + if previousUser.id == user.id { + passthroughPreviousResult = previousResults + } + } + } + return .contextRequestResult(user, passthroughPreviousResult) + }) + + let maybeDelayedContextResults: Signal<(ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?, NoError> + if delayRequest { + maybeDelayedContextResults = contextResults |> delay(0.4, queue: Queue.concurrentDefaultQueue()) + } else { + maybeDelayedContextResults = contextResults + } + + return botResult |> then(maybeDelayedContextResults) + } else { + return .single({ _ in return nil }) + } + } + + return (inputQuery, signal |> then(contextBot)) + } + } + } else { + return (nil, .single({ _ in return nil })) + } +} diff --git a/TelegramUI/ChatInterfaceTitlePanelNodes.swift b/TelegramUI/ChatInterfaceTitlePanelNodes.swift new file mode 100644 index 0000000000..5994d0545e --- /dev/null +++ b/TelegramUI/ChatInterfaceTitlePanelNodes.swift @@ -0,0 +1,36 @@ +import Foundation +import TelegramCore + +func titlePanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState, account: Account, currentPanel: ChatTitleAccessoryPanelNode?, interfaceInteraction: ChatPanelInterfaceInteraction?) -> ChatTitleAccessoryPanelNode? { + if !chatPresentationInterfaceState.titlePanelContexts.isEmpty { + switch chatPresentationInterfaceState.titlePanelContexts[chatPresentationInterfaceState.titlePanelContexts.count - 1] { + case .chatInfo: + if let currentPanel = currentPanel as? ChatInfoTitlePanelNode { + return currentPanel + } else { + let panel = ChatInfoTitlePanelNode() + panel.interfaceInteraction = interfaceInteraction + return panel + } + case .requestInProgress: + if let currentPanel = currentPanel as? ChatRequestInProgressTitlePanelNode { + return currentPanel + } else { + let panel = ChatRequestInProgressTitlePanelNode() + panel.interfaceInteraction = interfaceInteraction + return panel + } + case let .toastAlert(text): + if let currentPanel = currentPanel as? ChatToastAlertPanelNode { + currentPanel.text = text + return currentPanel + } else { + let panel = ChatToastAlertPanelNode() + panel.text = text + panel.interfaceInteraction = interfaceInteraction + return panel + } + } + } + return nil +} diff --git a/TelegramUI/ChatListEmptyItem.swift b/TelegramUI/ChatListEmptyItem.swift index a8e006d700..8bf07ceaba 100644 --- a/TelegramUI/ChatListEmptyItem.swift +++ b/TelegramUI/ChatListEmptyItem.swift @@ -55,6 +55,6 @@ class ChatListEmptyItemNode: ListViewItemNode { } func updateItemPosition(first: Bool, last: Bool) { - self.insets = UIEdgeInsets(top: first ? 4.0 : 0.0, left: 0.0, bottom: 0.0, right: 0.0) + self.insets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0) } } diff --git a/TelegramUI/ChatListItem.swift b/TelegramUI/ChatListItem.swift index 33c0833ba0..2021917ace 100644 --- a/TelegramUI/ChatListItem.swift +++ b/TelegramUI/ChatListItem.swift @@ -29,7 +29,7 @@ class ChatListItem: ListViewItem { async { let node = ChatListItemNode() node.setupItem(account: self.account, message: self.message, combinedReadState: self.combinedReadState, notificationSettings: self.notificationSettings, embeddedState: self.embeddedState) - node.relativePosition = (first: previousItem == nil, last: nextItem == nil) + node.relativePosition = (first: previousItem == nil || previousItem! is ChatListSearchItem, last: nextItem == nil) node.insets = ChatListItemNode.insets(first: node.relativePosition.first, last: node.relativePosition.last) node.layoutForWidth(width, item: self, previousItem: previousItem, nextItem: nextItem) completion(node, {}) @@ -43,7 +43,7 @@ class ChatListItem: ListViewItem { node.setupItem(account: self.account, message: self.message, combinedReadState: self.combinedReadState, notificationSettings: self.notificationSettings, embeddedState: self.embeddedState) let layout = node.asyncLayout() async { - let first = previousItem == nil + let first = previousItem == nil || previousItem! is ChatListSearchItem let last = nextItem == nil let (nodeLayout, apply) = layout(self.account, width, first, last) @@ -232,11 +232,12 @@ class ChatListItemNode: ListViewItemNode { let insets = self.insets self.backgroundNode.frame = CGRect(origin: CGPoint(), size: size) - self.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -insets.top - separatorHeight), size: CGSize(width: size.width, height: size.height + separatorHeight)) + let topNegativeInset: CGFloat = self.relativePosition.first ? 4.0 : 0.0 + self.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -insets.top - separatorHeight - topNegativeInset), size: CGSize(width: size.width, height: size.height + separatorHeight + topNegativeInset)) } class func insets(first: Bool, last: Bool) -> UIEdgeInsets { - return UIEdgeInsets(top: first ? 4.0 : 0.0, left: 0.0, bottom: 0.0, right: 0.0) + return UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0) } override func setHighlighted(_ highlighted: Bool, animated: Bool) { diff --git a/TelegramUI/ChatListSearchItem.swift b/TelegramUI/ChatListSearchItem.swift index 55a1543374..8b1387662c 100644 --- a/TelegramUI/ChatListSearchItem.swift +++ b/TelegramUI/ChatListSearchItem.swift @@ -86,7 +86,7 @@ class ChatListSearchItemNode: ListViewItemNode { return { width in let searchBarApply = searchBarNodeLayout(NSAttributedString(string: placeholder ?? "Search", font: searchBarFont, textColor: UIColor(0x8e8e93)), CGSize(width: width - 16.0, height: CGFloat.greatestFiniteMagnitude)) - let layout = ListViewItemNodeLayout(contentSize: CGSize(width: width, height: 44.0), insets: UIEdgeInsets()) + let layout = ListViewItemNodeLayout(contentSize: CGSize(width: width, height: 44.0 + 4.0), insets: UIEdgeInsets()) return (layout, { [weak self] in if let strongSelf = self { diff --git a/TelegramUI/ChatMessageBubbleItemNode.swift b/TelegramUI/ChatMessageBubbleItemNode.swift index 39e98d82f4..40d4eee5cb 100644 --- a/TelegramUI/ChatMessageBubbleItemNode.swift +++ b/TelegramUI/ChatMessageBubbleItemNode.swift @@ -767,7 +767,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { if let sourceMessageId = forwardInfo.sourceMessageId { self.controllerInteraction?.navigateToMessage(item.message.id, sourceMessageId) } else { - self.controllerInteraction?.openPeer(forwardInfo.source?.id ?? forwardInfo.author.id, .chat) + self.controllerInteraction?.openPeer(forwardInfo.source?.id ?? forwardInfo.author.id, .chat(textInputState: nil)) } return } @@ -787,7 +787,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { case let .peerMention(peerId): foundTapAction = true if let controllerInteraction = self.controllerInteraction { - controllerInteraction.openPeer(peerId, .info) + controllerInteraction.openPeer(peerId, .chat(textInputState: nil)) } break loop case let .textMention(name): @@ -933,7 +933,26 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { case let .callback(data): controllerInteraction.requestMessageActionCallback(item.message.id, data) case let .switchInline(samePeer, query): - break + var botPeer: Peer? + + var found = false + for attribute in item.message.attributes { + if let attribute = attribute as? InlineBotMessageAttribute { + botPeer = item.message.peers[attribute.peerId] + found = true + } + } + if !found { + botPeer = item.message.author + } + + var peerId: PeerId? + if samePeer { + peerId = item.message.id.peerId + } + if let botPeer = botPeer, let addressName = botPeer.addressName { + controllerInteraction.openPeer(peerId, .chat(textInputState: ChatTextInputState(inputText: "@\(addressName) \(query)"))) + } } } } diff --git a/TelegramUI/ChatMessageDateHeader.swift b/TelegramUI/ChatMessageDateHeader.swift index f19274f616..a7efad746f 100644 --- a/TelegramUI/ChatMessageDateHeader.swift +++ b/TelegramUI/ChatMessageDateHeader.swift @@ -41,7 +41,7 @@ final class ChatMessageDateHeader: ListViewItemHeader { private func backgroundImage(color: UIColor) -> UIImage? { return generateImage(CGSize(width: 26.0, height: 26.0), contextGenerator: { size, context -> Void in context.clear(CGRect(origin: CGPoint(), size: size)) - context.setFillColor(UIColor(0x748391, 0.45).cgColor) + context.setFillColor(color.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) })?.stretchableImage(withLeftCapWidth: 13, topCapHeight: 13) } @@ -66,6 +66,7 @@ private let months: [String] = [ final class ChatMessageDateHeaderNode: ListViewItemHeaderNode { let labelNode: TextNode let backgroundNode: ASImageNode + let stickBackgroundNode: ASImageNode private let timestamp: Int32 @@ -84,12 +85,19 @@ final class ChatMessageDateHeaderNode: ListViewItemHeaderNode { self.backgroundNode.displayWithoutProcessing = true self.backgroundNode.displaysAsynchronously = false + self.stickBackgroundNode = ASImageNode() + self.stickBackgroundNode.isLayerBacked = true + self.stickBackgroundNode.displayWithoutProcessing = true + self.stickBackgroundNode.displaysAsynchronously = false + super.init(dynamicBounce: true) self.isLayerBacked = true self.transform = CATransform3DMakeRotation(CGFloat(M_PI), 0.0, 0.0, 1.0) - self.backgroundNode.image = backgroundImage(color: UIColor(0x007ee5)) + self.backgroundNode.image = backgroundImage(color: UIColor(0x748391, 0.45)) + self.stickBackgroundNode.image = backgroundImage(color: UIColor(0x939fab, 0.5)) + self.backgroundNode.addSubnode(self.stickBackgroundNode) self.addSubnode(self.backgroundNode) self.addSubnode(self.labelNode) @@ -129,12 +137,15 @@ final class ChatMessageDateHeaderNode: ListViewItemHeaderNode { let backgroundSize = CGSize(width: size.width + 8.0 + 8.0, height: 26.0) let backgroundFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((bounds.size.width - backgroundSize.width) / 2.0), y: (34.0 - 26.0) / 2.0), size: backgroundSize) + self.stickBackgroundNode.frame = CGRect(origin: CGPoint(), size: backgroundFrame.size) self.backgroundNode.frame = backgroundFrame self.labelNode.frame = CGRect(origin: CGPoint(x: backgroundFrame.origin.x + 8.0, y: backgroundFrame.origin.y + floorToScreenPixels((backgroundSize.height - size.height) / 2.0) - 1.0), size: size) } override func updateStickDistanceFactor(_ factor: CGFloat, transition: ContainedViewLayoutTransition) { if !self.stickDistanceFactor.isEqual(to: factor) { + self.stickBackgroundNode.alpha = factor + let wasZero = self.stickDistanceFactor < 0.5 let isZero = factor < 0.5 self.stickDistanceFactor = factor diff --git a/TelegramUI/ChatMessageInteractiveFileNode.swift b/TelegramUI/ChatMessageInteractiveFileNode.swift index fe9a2ee1c8..e65933039e 100644 --- a/TelegramUI/ChatMessageInteractiveFileNode.swift +++ b/TelegramUI/ChatMessageInteractiveFileNode.swift @@ -144,7 +144,15 @@ final class ChatMessageInteractiveFileNode: ASTransformNode { for attribute in file.attributes { if case let .Audio(_, _, title, performer, _) = attribute { candidateTitleString = NSAttributedString(string: title ?? "Unknown Track", font: titleFont, textColor: incoming ? incomingTitleColor : outgoingTitleColor) - candidateDescriptionString = NSAttributedString(string: performer ?? dataSizeString(file.size), font: descriptionFont, textColor:incoming ? incomingDescriptionColor : outgoingDescriptionColor) + let descriptionText: String + if let performer = performer { + descriptionText = performer + } else if let size = file.size { + descriptionText = dataSizeString(size) + } else { + descriptionText = "" + } + candidateDescriptionString = NSAttributedString(string: descriptionText, font: descriptionFont, textColor:incoming ? incomingDescriptionColor : outgoingDescriptionColor) break } } @@ -161,7 +169,13 @@ final class ChatMessageInteractiveFileNode: ASTransformNode { if let candidateDescriptionString = candidateDescriptionString { descriptionString = candidateDescriptionString } else { - descriptionString = NSAttributedString(string: dataSizeString(file.size), font: descriptionFont, textColor:incoming ? incomingDescriptionColor : outgoingDescriptionColor) + let descriptionText: String + if let size = file.size { + descriptionText = dataSizeString(size) + } else { + descriptionText = "" + } + descriptionString = NSAttributedString(string: descriptionText, font: descriptionFont, textColor:incoming ? incomingDescriptionColor : outgoingDescriptionColor) } let textConstrainedSize = CGSize(width: constrainedSize.width - 44.0 - 8.0, height: constrainedSize.height) diff --git a/TelegramUI/ChatMessageInteractiveMediaNode.swift b/TelegramUI/ChatMessageInteractiveMediaNode.swift index ad61d1844c..8d10d6fec2 100644 --- a/TelegramUI/ChatMessageInteractiveMediaNode.swift +++ b/TelegramUI/ChatMessageInteractiveMediaNode.swift @@ -103,7 +103,7 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { let drawingSize = nativeSize.fittedToWidthOrSmaller(boundingWidth) let boundingSize = CGSize(width: max(boundingWidth, drawingSize.width), height: drawingSize.height).cropped(layoutConstants.image.maxDimensions) - var updateImageSignal: Signal<(TransformImageArguments) -> DrawingContext, NoError>? + var updateImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? var updatedStatusSignal: Signal? var updatedFetchControls: FetchControls? diff --git a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift index 4522fe8cb2..b2008cde61 100644 --- a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift +++ b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift @@ -88,7 +88,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { var textString: NSAttributedString? var inlineImageDimensions: CGSize? var inlineImageSize: CGSize? - var updateInlineImageSignal: Signal<(TransformImageArguments) -> DrawingContext, NoError>? + var updateInlineImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? var textCutout: TextNodeCutout? var initialWidth: CGFloat = CGFloat.greatestFiniteMagnitude var refineContentImageLayout: ((CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> ChatMessageInteractiveMediaNode)))? diff --git a/TelegramUI/ChatPanelInterfaceInteraction.swift b/TelegramUI/ChatPanelInterfaceInteraction.swift index 0de1a3b745..0617901561 100644 --- a/TelegramUI/ChatPanelInterfaceInteraction.swift +++ b/TelegramUI/ChatPanelInterfaceInteraction.swift @@ -1,6 +1,7 @@ import Foundation import Postbox import SwiftSignalKit +import TelegramCore final class ChatPanelInterfaceInteractionStatuses { let editingMessage: Signal @@ -16,12 +17,16 @@ final class ChatPanelInterfaceInteraction { let beginMessageSelection: (MessageId) -> Void let deleteSelectedMessages: () -> Void let forwardSelectedMessages: () -> Void - let updateTextInputState: (ChatTextInputState) -> Void + let updateTextInputState: (@escaping (ChatTextInputState) -> ChatTextInputState) -> Void let updateInputMode: ((ChatInputMode) -> ChatInputMode) -> Void let editMessage: (MessageId, String) -> Void + let beginMessageSearch: () -> Void + let openPeerInfo: () -> Void + let togglePeerNotifications: () -> Void + let sendContextResult: (ChatContextResultCollection, ChatContextResult) -> Void let statuses: ChatPanelInterfaceInteractionStatuses? - init(setupReplyMessage: @escaping (MessageId) -> Void, setupEditMessage: @escaping (MessageId) -> Void, beginMessageSelection: @escaping (MessageId) -> Void, deleteSelectedMessages: @escaping () -> Void, forwardSelectedMessages: @escaping () -> Void, updateTextInputState: @escaping (ChatTextInputState) -> Void, updateInputMode: @escaping ((ChatInputMode) -> ChatInputMode) -> Void, editMessage: @escaping (MessageId, String) -> Void, statuses: ChatPanelInterfaceInteractionStatuses?) { + init(setupReplyMessage: @escaping (MessageId) -> Void, setupEditMessage: @escaping (MessageId) -> Void, beginMessageSelection: @escaping (MessageId) -> Void, deleteSelectedMessages: @escaping () -> Void, forwardSelectedMessages: @escaping () -> Void, updateTextInputState: @escaping ((ChatTextInputState) -> ChatTextInputState) -> Void, updateInputMode: @escaping ((ChatInputMode) -> ChatInputMode) -> Void, editMessage: @escaping (MessageId, String) -> Void, beginMessageSearch: @escaping () -> Void, openPeerInfo: @escaping () -> Void, togglePeerNotifications: @escaping () -> Void, sendContextResult: @escaping (ChatContextResultCollection, ChatContextResult) -> Void, statuses: ChatPanelInterfaceInteractionStatuses?) { self.setupReplyMessage = setupReplyMessage self.setupEditMessage = setupEditMessage self.beginMessageSelection = beginMessageSelection @@ -30,6 +35,10 @@ final class ChatPanelInterfaceInteraction { self.updateTextInputState = updateTextInputState self.updateInputMode = updateInputMode self.editMessage = editMessage + self.beginMessageSearch = beginMessageSearch + self.openPeerInfo = openPeerInfo + self.togglePeerNotifications = togglePeerNotifications + self.sendContextResult = sendContextResult self.statuses = statuses } } diff --git a/TelegramUI/ChatPresentationInterfaceState.swift b/TelegramUI/ChatPresentationInterfaceState.swift index 749c9a83e9..bb848c70b5 100644 --- a/TelegramUI/ChatPresentationInterfaceState.swift +++ b/TelegramUI/ChatPresentationInterfaceState.swift @@ -1,9 +1,101 @@ import Foundation import Postbox +import TelegramCore -enum ChatPresentationInputContext { - case hashtag - case mention +enum ChatPresentationInputQuery: Equatable { + case hashtag(String) + case mention(String) + case command(String) + case contextRequest(addressName: String, query: String) + + static func ==(lhs: ChatPresentationInputQuery, rhs: ChatPresentationInputQuery) -> Bool { + switch lhs { + case let .hashtag(query): + if case .hashtag(query) = rhs { + return true + } else { + return false + } + case let .mention(query): + if case .mention(query) = rhs { + return true + } else { + return false + } + case let .command(query): + if case .command(query) = rhs { + return true + } else { + return false + } + case let .contextRequest(addressName, query): + if case .contextRequest(addressName, query) = rhs { + return true + } else { + return false + } + } + } +} + +enum ChatPresentationInputQueryResult: Equatable { + case hashtags([String]) + case mentions([Peer]) + case commands([(Peer, String)]) + case contextRequestResult(Peer, ChatContextResultCollection?) + + static func ==(lhs: ChatPresentationInputQueryResult, rhs: ChatPresentationInputQueryResult) -> Bool { + switch lhs { + case let .hashtags(lhsResults): + if case let .hashtags(rhsResults) = rhs { + return lhsResults == rhsResults + } else { + return false + } + case let .mentions(lhsPeers): + if case let .mentions(rhsPeers) = rhs { + if lhsPeers.count != rhsPeers.count { + return false + } else { + for i in 0 ..< lhsPeers.count { + if !lhsPeers[i].isEqual(rhsPeers[i]) { + return false + } + } + return true + } + } else { + return false + } + case let .commands(lhsCommands): + if case let .commands(rhsCommands) = rhs { + if lhsCommands.count != rhsCommands.count { + return false + } else { + for i in 0 ..< lhsCommands.count { + if !lhsCommands[i].0.isEqual(rhsCommands[i].0) || lhsCommands[i].1 != rhsCommands[i].1 { + return false + } + } + return true + } + } else { + return false + } + case let .contextRequestResult(lhsPeer, lhsCollection): + if case let .contextRequestResult(rhsPeer, rhsCollection) = rhs { + if !lhsPeer.isEqual(rhsPeer) { + return false + } + if lhsCollection != rhsCollection { + return false + } + return true + } else { + return false + } + } + } } enum ChatInputMode { @@ -12,27 +104,74 @@ enum ChatInputMode { case media } +enum ChatTitlePanelContext: Comparable { + case chatInfo + case requestInProgress + case toastAlert(String) + + private var index: Int { + switch self { + case .chatInfo: + return 0 + case .requestInProgress: + return 1 + case .toastAlert: + return 2 + } + } + + static func ==(lhs: ChatTitlePanelContext, rhs: ChatTitlePanelContext) -> Bool { + switch lhs { + case .chatInfo: + if case .chatInfo = rhs { + return true + } else { + return false + } + case .requestInProgress: + if case .requestInProgress = rhs { + return true + } else { + return false + } + case let .toastAlert(text): + if case .toastAlert(text) = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: ChatTitlePanelContext, rhs: ChatTitlePanelContext) -> Bool { + return lhs.index < rhs.index + } +} + struct ChatPresentationInterfaceState: Equatable { let interfaceState: ChatInterfaceState let peer: Peer? let inputTextPanelState: ChatTextInputPanelState - let inputContext: ChatPresentationInputContext? + let inputQueryResult: ChatPresentationInputQueryResult? let inputMode: ChatInputMode + let titlePanelContexts: [ChatTitlePanelContext] init() { self.interfaceState = ChatInterfaceState() self.inputTextPanelState = ChatTextInputPanelState() self.peer = nil - self.inputContext = nil + self.inputQueryResult = nil self.inputMode = .none + self.titlePanelContexts = [] } - init(interfaceState: ChatInterfaceState, peer: Peer?, inputTextPanelState: ChatTextInputPanelState, inputContext: ChatPresentationInputContext?, inputMode: ChatInputMode) { + init(interfaceState: ChatInterfaceState, peer: Peer?, inputTextPanelState: ChatTextInputPanelState, inputQueryResult: ChatPresentationInputQueryResult?, inputMode: ChatInputMode, titlePanelContexts: [ChatTitlePanelContext]) { self.interfaceState = interfaceState self.peer = peer self.inputTextPanelState = inputTextPanelState - self.inputContext = inputContext + self.inputQueryResult = inputQueryResult self.inputMode = inputMode + self.titlePanelContexts = titlePanelContexts } static func ==(lhs: ChatPresentationInterfaceState, rhs: ChatPresentationInterfaceState) -> Bool { @@ -51,7 +190,7 @@ struct ChatPresentationInterfaceState: Equatable { return false } - if lhs.inputContext != rhs.inputContext { + if lhs.inputQueryResult != rhs.inputQueryResult { return false } @@ -59,26 +198,34 @@ struct ChatPresentationInterfaceState: Equatable { return false } + if lhs.titlePanelContexts != rhs.titlePanelContexts { + return false + } + return true } func updatedInterfaceState(_ f: (ChatInterfaceState) -> ChatInterfaceState) -> ChatPresentationInterfaceState { - return ChatPresentationInterfaceState(interfaceState: f(self.interfaceState), peer: self.peer, inputTextPanelState: self.inputTextPanelState, inputContext: self.inputContext, inputMode: self.inputMode) + return ChatPresentationInterfaceState(interfaceState: f(self.interfaceState), peer: self.peer, inputTextPanelState: self.inputTextPanelState, inputQueryResult: self.inputQueryResult, inputMode: self.inputMode, titlePanelContexts: self.titlePanelContexts) } func updatedPeer(_ f: (Peer?) -> Peer?) -> ChatPresentationInterfaceState { - return ChatPresentationInterfaceState(interfaceState: self.interfaceState, peer: f(self.peer), inputTextPanelState: self.inputTextPanelState, inputContext: self.inputContext, inputMode: self.inputMode) + return ChatPresentationInterfaceState(interfaceState: self.interfaceState, peer: f(self.peer), inputTextPanelState: self.inputTextPanelState, inputQueryResult: self.inputQueryResult, inputMode: self.inputMode, titlePanelContexts: self.titlePanelContexts) } - func updatedInputContext(_ f: (ChatPresentationInputContext?) -> ChatPresentationInputContext?) -> ChatPresentationInterfaceState { - return ChatPresentationInterfaceState(interfaceState: self.interfaceState, peer: self.peer, inputTextPanelState: self.inputTextPanelState, inputContext: f(self.inputContext), inputMode: self.inputMode) + func updatedInputQueryResult(_ f: (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult?) -> ChatPresentationInterfaceState { + return ChatPresentationInterfaceState(interfaceState: self.interfaceState, peer: self.peer, inputTextPanelState: self.inputTextPanelState, inputQueryResult: f(self.inputQueryResult), inputMode: self.inputMode, titlePanelContexts: self.titlePanelContexts) } func updatedInputTextPanelState(_ f: (ChatTextInputPanelState) -> ChatTextInputPanelState) -> ChatPresentationInterfaceState { - return ChatPresentationInterfaceState(interfaceState: self.interfaceState, peer: self.peer, inputTextPanelState: f(self.inputTextPanelState), inputContext: self.inputContext, inputMode: self.inputMode) + return ChatPresentationInterfaceState(interfaceState: self.interfaceState, peer: self.peer, inputTextPanelState: f(self.inputTextPanelState), inputQueryResult: self.inputQueryResult, inputMode: self.inputMode, titlePanelContexts: self.titlePanelContexts) } func updatedInputMode(_ f: (ChatInputMode) -> ChatInputMode) -> ChatPresentationInterfaceState { - return ChatPresentationInterfaceState(interfaceState: self.interfaceState, peer: self.peer, inputTextPanelState: self.inputTextPanelState, inputContext: self.inputContext, inputMode: f(self.inputMode)) + return ChatPresentationInterfaceState(interfaceState: self.interfaceState, peer: self.peer, inputTextPanelState: self.inputTextPanelState, inputQueryResult: self.inputQueryResult, inputMode: f(self.inputMode), titlePanelContexts: self.titlePanelContexts) + } + + func updatedTitlePanelContext(_ f: ([ChatTitlePanelContext]) -> [ChatTitlePanelContext]) -> ChatPresentationInterfaceState { + return ChatPresentationInterfaceState(interfaceState: self.interfaceState, peer: self.peer, inputTextPanelState: self.inputTextPanelState, inputQueryResult: self.inputQueryResult, inputMode: self.inputMode, titlePanelContexts: f(self.titlePanelContexts)) } } diff --git a/TelegramUI/ChatRequestInProgressTitlePanelNode.swift b/TelegramUI/ChatRequestInProgressTitlePanelNode.swift new file mode 100644 index 0000000000..869599a809 --- /dev/null +++ b/TelegramUI/ChatRequestInProgressTitlePanelNode.swift @@ -0,0 +1,36 @@ +import Foundation +import Display +import AsyncDisplayKit + +final class ChatRequestInProgressTitlePanelNode: ChatTitleAccessoryPanelNode { + private let separatorNode: ASDisplayNode + private let titleNode: ASTextNode + + override init() { + self.separatorNode = ASDisplayNode() + self.separatorNode.backgroundColor = UIColor(red: 0.6953125, green: 0.6953125, blue: 0.6953125, alpha: 1.0) + self.separatorNode.isLayerBacked = true + + self.titleNode = ASTextNode() + self.titleNode.attributedText = NSAttributedString(string: "Loading...", font: Font.regular(14.0), textColor: UIColor.black) + self.titleNode.maximumNumberOfLines = 1 + + super.init() + + self.backgroundColor = UIColor(0xF5F6F8) + + self.addSubnode(self.titleNode) + self.addSubnode(self.separatorNode) + } + + override func updateLayout(width: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> CGFloat { + let panelHeight: CGFloat = 40.0 + + let titleSize = self.titleNode.measure(CGSize(width: width, height: 100.0)) + transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: floor((width - titleSize.width) / 2.0), y: floor((panelHeight - titleSize.height) / 2.0)), size: titleSize)) + + transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: panelHeight - UIScreenPixel), size: CGSize(width: width, height: UIScreenPixel))) + + return panelHeight + } +} diff --git a/TelegramUI/ChatTextInputPanelNode.swift b/TelegramUI/ChatTextInputPanelNode.swift index 8c9c0337d0..380b0deecb 100644 --- a/TelegramUI/ChatTextInputPanelNode.swift +++ b/TelegramUI/ChatTextInputPanelNode.swift @@ -23,6 +23,43 @@ private let textInputViewBackground: UIImage = { return image }() +private let searchLayoutClearButtonImage = generateImage(CGSize(width: 14.0, height: 14.0), contextGenerator: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor(0x9099A2, 0.6).cgColor) + context.setStrokeColor(UIColor.white.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + + context.setLineWidth(1.5) + context.setLineCap(.round) + context.translateBy(x: size.width / 2.0, y: size.height / 2.0) + context.rotate(by: CGFloat(M_PI / 4)) + context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) + + let lineHeight: CGFloat = 7.0 + + context.beginPath() + context.move(to: CGPoint(x: size.width / 2.0, y: (size.width - lineHeight) / 2.0)) + context.addLine(to: CGPoint(x: size.width / 2.0, y: (size.width - lineHeight) / 2.0 + lineHeight)) + context.strokePath() + + context.beginPath() + context.move(to: CGPoint(x: (size.width - lineHeight) / 2.0, y: size.width / 2.0)) + context.addLine(to: CGPoint(x: (size.width - lineHeight) / 2.0 + lineHeight, y: size.width / 2.0)) + context.strokePath() +}) + +private let searchLayoutProgressImage = generateImage(CGSize(width: 22.0, height: 22.0), contextGenerator: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setStrokeColor(UIColor(0x9099A2, 0.6).cgColor) + + let lineWidth: CGFloat = 2.0 + let cutoutWidth: CGFloat = 4.0 + context.setLineWidth(lineWidth) + + context.strokeEllipse(in: CGRect(origin: CGPoint(x: lineWidth / 2.0, y: lineWidth / 2.0), size: CGSize(width: size.width - lineWidth, height: size.height - lineWidth))) + context.clear(CGRect(origin: CGPoint(x: (size.width - cutoutWidth) / 2.0, y: 0.0), size: CGSize(width: cutoutWidth, height: size.height / 2.0))) +}) + private let attachmentIcon = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Text/IconAttachment"), color: UIColor(0x9099A2)) private let micIcon = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Text/IconMicrophone"), color: UIColor(0x9099A2)) private let sendIcon = UIImage(bundleImageName: "Chat/Input/Text/IconSend")?.precomposed() @@ -56,7 +93,7 @@ private let keyboardImage = UIImage(bundleImageName: "Chat/Input/Text/AccessoryI private let stickersImage = UIImage(bundleImageName: "Chat/Input/Text/AccessoryIconStickers")?.precomposed() private let inputButtonsImage = UIImage(bundleImageName: "Chat/Input/Text/AccessoryIconInputButtons")?.precomposed() -private final class AccessoryItemIconButton: UIButton { +private final class AccessoryItemIconButton: HighlightableButton { init(item: ChatTextInputAccessoryItem) { super.init(frame: CGRect()) @@ -86,9 +123,11 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { var textInputNode: ASEditableTextNode? let textInputBackgroundView: UIImageView - let micButton: UIButton - let sendButton: UIButton - let attachmentButton: UIButton + let micButton: HighlightableButton + let sendButton: HighlightableButton + let attachmentButton: HighlightableButton + let searchLayoutClearButton: HighlightableButton + let searchLayoutProgressView: UIImageView private var accessoryItemButtons: [(ChatTextInputAccessoryItem, AccessoryItemIconButton)] = [] @@ -103,6 +142,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { private var presentationInterfaceState = ChatPresentationInterfaceState() private var keepSendButtonEnabled = false + private var extendedSearchLayout = false var inputTextState: ChatTextInputState { if let textInputNode = self.textInputNode { @@ -114,7 +154,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { } } - func updateInputTextState(_ state: ChatTextInputState, keepSendButtonEnabled: Bool, animated: Bool) { + func updateInputTextState(_ state: ChatTextInputState, keepSendButtonEnabled: Bool, extendedSearchLayout: Bool, animated: Bool) { if !state.inputText.isEmpty && self.textInputNode == nil { self.loadTextInputNode() } @@ -125,13 +165,15 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { textInputNode.selectedRange = NSMakeRange(state.selectionRange.lowerBound, state.selectionRange.count) self.updatingInputState = false self.keepSendButtonEnabled = keepSendButtonEnabled + self.extendedSearchLayout = extendedSearchLayout self.updateTextNodeText(animated: animated) } } - func updateKeepSendButtonEnabled(keepSendButtonEnabled: Bool, animated: Bool) { - if keepSendButtonEnabled != self.keepSendButtonEnabled { + func updateKeepSendButtonEnabled(keepSendButtonEnabled: Bool, extendedSearchLayout: Bool, animated: Bool) { + if keepSendButtonEnabled != self.keepSendButtonEnabled || extendedSearchLayout != self.extendedSearchLayout { self.keepSendButtonEnabled = keepSendButtonEnabled + self.extendedSearchLayout = extendedSearchLayout self.updateTextNodeText(animated: animated) } } @@ -156,9 +198,12 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { self.textInputBackgroundView = UIImageView(image: textInputViewBackground) self.textPlaceholderNode = TextNode() self.textPlaceholderNode.isLayerBacked = true - self.attachmentButton = UIButton() - self.micButton = UIButton() - self.sendButton = UIButton() + self.attachmentButton = HighlightableButton() + self.searchLayoutClearButton = HighlightableButton() + self.searchLayoutProgressView = UIImageView(image: searchLayoutProgressImage) + self.searchLayoutProgressView.isHidden = true + self.micButton = HighlightableButton() + self.sendButton = HighlightableButton() super.init() @@ -175,6 +220,12 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { self.sendButton.alpha = 0.0 self.view.addSubview(self.sendButton) + self.searchLayoutClearButton.setImage(searchLayoutClearButtonImage, for: []) + self.searchLayoutClearButton.addTarget(self, action: #selector(self.searchLayoutClearButtonPressed), for: .touchUpInside) + self.searchLayoutClearButton.alpha = 0.0 + + self.searchLayoutClearButton.addSubview(self.searchLayoutProgressView) + self.view.addSubview(self.textInputBackgroundView) let placeholderLayout = TextNode.asyncLayout(self.textPlaceholderNode) @@ -183,6 +234,8 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { let _ = placeholderApply() self.addSubnode(self.textPlaceholderNode) + self.view.addSubview(self.searchLayoutClearButton) + self.textInputBackgroundView.clipsToBounds = true let recognizer = TouchDownGestureRecognizer(target: self, action: #selector(self.textInputBackgroundViewTap(_:))) recognizer.touchDown = { [weak self] in @@ -329,8 +382,21 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { let panelHeight = self.panelHeight(textFieldHeight: textFieldHeight) transition.updateFrame(layer: self.attachmentButton.layer, frame: CGRect(origin: CGPoint(x: 2.0 - UIScreenPixel, y: panelHeight - minimalHeight), size: CGSize(width: 40.0, height: minimalHeight))) - transition.updateFrame(layer: self.micButton.layer, frame: CGRect(origin: CGPoint(x: width - 43.0 - UIScreenPixel, y: panelHeight - minimalHeight - UIScreenPixel), size: CGSize(width: 44.0, height: minimalHeight))) - transition.updateFrame(layer: self.sendButton.layer, frame: CGRect(origin: CGPoint(x: width - 43.0 - UIScreenPixel, y: panelHeight - minimalHeight - UIScreenPixel), size: CGSize(width: 44.0, height: minimalHeight))) + + var composeButtonsOffset: CGFloat = 0.0 + var textInputBackgroundWidthOffset: CGFloat = 0.0 + if self.extendedSearchLayout { + composeButtonsOffset = 44.0 + textInputBackgroundWidthOffset = 36.0 + } + transition.updateFrame(layer: self.micButton.layer, frame: CGRect(origin: CGPoint(x: width - 43.0 - UIScreenPixel + composeButtonsOffset, y: panelHeight - minimalHeight - UIScreenPixel), size: CGSize(width: 44.0, height: minimalHeight))) + transition.updateFrame(layer: self.sendButton.layer, frame: CGRect(origin: CGPoint(x: width - 43.0 - UIScreenPixel + composeButtonsOffset, y: panelHeight - minimalHeight - UIScreenPixel), size: CGSize(width: 44.0, height: minimalHeight))) + + let searchLayoutClearButtonSize = CGSize(width: 44.0, height: minimalHeight) + transition.updateFrame(layer: self.searchLayoutClearButton.layer, frame: CGRect(origin: CGPoint(x: width - self.textFieldInsets.left - self.textFieldInsets.right + textInputBackgroundWidthOffset + 3.0, y: panelHeight - minimalHeight), size: searchLayoutClearButtonSize)) + + let searchProgressSize = self.searchLayoutProgressView.bounds.size + transition.updateFrame(layer: self.searchLayoutProgressView.layer, frame: CGRect(origin: CGPoint(x: floor((searchLayoutClearButtonSize.width - searchProgressSize.width) / 2.0), y: floor((searchLayoutClearButtonSize.height - searchProgressSize.height) / 2.0)), size: searchProgressSize)) if let textInputNode = self.textInputNode { transition.updateFrame(node: textInputNode, frame: CGRect(x: self.textFieldInsets.left + self.textInputViewInternalInsets.left, y: self.textFieldInsets.top + self.textInputViewInternalInsets.top, width: width - self.textFieldInsets.left - self.textFieldInsets.right - self.textInputViewInternalInsets.left - self.textInputViewInternalInsets.right - accessoryButtonsWidth, height: panelHeight - self.textFieldInsets.top - self.textFieldInsets.bottom - self.textInputViewInternalInsets.top - self.textInputViewInternalInsets.bottom)) @@ -338,7 +404,7 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { transition.updateFrame(node: self.textPlaceholderNode, frame: CGRect(origin: CGPoint(x: self.textFieldInsets.left + self.textInputViewInternalInsets.left, y: self.textFieldInsets.top + self.textInputViewInternalInsets.top + 0.5), size: self.textPlaceholderNode.frame.size)) - transition.updateFrame(layer: self.textInputBackgroundView.layer, frame: CGRect(x: self.textFieldInsets.left, y: self.textFieldInsets.top, width: width - self.textFieldInsets.left - self.textFieldInsets.right, height: panelHeight - self.textFieldInsets.top - self.textFieldInsets.bottom)) + transition.updateFrame(layer: self.textInputBackgroundView.layer, frame: CGRect(x: self.textFieldInsets.left, y: self.textFieldInsets.top, width: width - self.textFieldInsets.left - self.textFieldInsets.right + textInputBackgroundWidthOffset, height: panelHeight - self.textFieldInsets.top - self.textFieldInsets.bottom)) var nextButtonTopRight = CGPoint(x: width - self.textFieldInsets.right - accessoryButtonInset, y: panelHeight - self.textFieldInsets.bottom - minimalInputHeight) for (_, button) in self.accessoryItemButtons.reversed() { @@ -375,7 +441,8 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { @objc func editableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode) { if let textInputNode = self.textInputNode { - self.interfaceInteraction?.updateTextInputState(self.inputTextState) + let inputTextState = self.inputTextState + self.interfaceInteraction?.updateTextInputState({ _ in return inputTextState }) self.updateTextNodeText(animated: true) } } @@ -387,24 +454,74 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { } self.textPlaceholderNode.isHidden = hasText - if hasText || self.keepSendButtonEnabled { - if self.sendButton.alpha.isZero { - self.sendButton.alpha = 1.0 + if self.extendedSearchLayout { + if !self.sendButton.alpha.isZero { + self.sendButton.alpha = 0.0 + if animated { + self.sendButton.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) + self.sendButton.layer.animateScale(from: 1.0, to: 0.2, duration: 0.2) + } + } + if !self.micButton.alpha.isZero { self.micButton.alpha = 0.0 if animated { - self.sendButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) - self.sendButton.layer.animateSpring(from: NSNumber(value: Float(0.1)), to: NSNumber(value: Float(1.0)), keyPath: "transform.scale", duration: 0.6) self.micButton.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) + self.micButton.layer.animateScale(from: 1.0, to: 0.2, duration: 0.2) + } + } + if self.searchLayoutClearButton.alpha.isZero { + self.searchLayoutClearButton.alpha = 1.0 + if animated { + self.searchLayoutClearButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) + self.searchLayoutClearButton.layer.animateScale(from: 0.8, to: 1.0, duration: 0.2) } } } else { - if self.micButton.alpha.isZero { - self.micButton.alpha = 1.0 - self.sendButton.alpha = 0.0 + var animateWithBounce = true + if !self.searchLayoutClearButton.alpha.isZero { + animateWithBounce = false + self.searchLayoutClearButton.alpha = 0.0 if animated { - self.micButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) - self.micButton.layer.animateSpring(from: NSNumber(value: Float(0.1)), to: NSNumber(value: Float(1.0)), keyPath: "transform.scale", duration: 0.6) - self.sendButton.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) + self.searchLayoutClearButton.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) + self.searchLayoutClearButton.layer.animateScale(from: 1.0, to: 0.8, duration: 0.2) + } + } + + if hasText || self.keepSendButtonEnabled { + if self.sendButton.alpha.isZero { + self.sendButton.alpha = 1.0 + if animated { + self.sendButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) + if animateWithBounce { + self.sendButton.layer.animateSpring(from: NSNumber(value: Float(0.1)), to: NSNumber(value: Float(1.0)), keyPath: "transform.scale", duration: 0.6) + } else { + self.sendButton.layer.animateScale(from: 0.2, to: 1.0, duration: 0.25) + } + } + } + if !self.micButton.alpha.isZero { + self.micButton.alpha = 0.0 + if animated { + self.micButton.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) + } + } + } else { + if self.micButton.alpha.isZero { + self.micButton.alpha = 1.0 + if animated { + self.micButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) + if animateWithBounce { + self.micButton.layer.animateSpring(from: NSNumber(value: Float(0.1)), to: NSNumber(value: Float(1.0)), keyPath: "transform.scale", duration: 0.6) + } else { + self.micButton.layer.animateScale(from: 0.2, to: 1.0, duration: 0.25) + } + } + } + if !self.sendButton.alpha.isZero { + self.sendButton.alpha = 0.0 + if animated { + self.sendButton.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) + } } } } @@ -418,7 +535,8 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { @objc func editableTextNodeDidChangeSelection(_ editableTextNode: ASEditableTextNode, fromSelectedRange: NSRange, toSelectedRange: NSRange, dueToEditing: Bool) { if !dueToEditing && !updatingInputState { - self.interfaceInteraction?.updateTextInputState(self.inputTextState) + let inputTextState = self.inputTextState + self.interfaceInteraction?.updateTextInputState({ _ in return inputTextState }) } } @@ -445,6 +563,23 @@ class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDelegate { self.displayAttachmentMenu() } + @objc func searchLayoutClearButtonPressed() { + if let interfaceInteraction = self.interfaceInteraction { + interfaceInteraction.updateTextInputState { textInputState in + if let (range, type, queryRange) = textInputStateContextQueryRangeAndType(textInputState), type == [.contextRequest] { + if let queryRange = queryRange, !queryRange.isEmpty { + var inputText = textInputState.inputText + inputText.replaceSubrange(queryRange, with: "") + return ChatTextInputState(inputText: inputText) + } else { + return ChatTextInputState(inputText: "") + } + } + return textInputState + } + } + } + @objc func micButtonPressed() { } diff --git a/TelegramUI/ChatTitleAccessoryPanelNode.swift b/TelegramUI/ChatTitleAccessoryPanelNode.swift new file mode 100644 index 0000000000..f7c65927e3 --- /dev/null +++ b/TelegramUI/ChatTitleAccessoryPanelNode.swift @@ -0,0 +1,11 @@ +import Foundation +import Display +import AsyncDisplayKit + +class ChatTitleAccessoryPanelNode: ASDisplayNode { + var interfaceInteraction: ChatPanelInterfaceInteraction? + + func updateLayout(width: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> CGFloat { + return 0.0 + } +} diff --git a/TelegramUI/ChatTitleView.swift b/TelegramUI/ChatTitleView.swift index cc70e109b9..122fa7ca6e 100644 --- a/TelegramUI/ChatTitleView.swift +++ b/TelegramUI/ChatTitleView.swift @@ -33,7 +33,13 @@ final class ChatTitleView: UIView { var shouldUpdateLayout = false if let peerView = self.peerView, let peer = peerView.peers[peerView.peerId] { if let user = peer as? TelegramUser { - if let presence = peerView.peerPresences[peerView.peerId] as? TelegramUserPresence { + if let _ = user.botInfo { + let string = NSAttributedString(string: "bot", font: Font.regular(13.0), textColor: UIColor(0x787878)) + if self.infoNode.attributedText == nil || !self.infoNode.attributedText!.isEqual(to: string) { + self.infoNode.attributedText = string + shouldUpdateLayout = true + } + } else if let presence = peerView.peerPresences[peerView.peerId] as? TelegramUserPresence { let timestamp = CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970 let (string, activity) = stringAndActivityForUserPresence(presence, relativeTo: Int32(timestamp)) let attributedString = NSAttributedString(string: string, font: Font.regular(13.0), textColor: activity ? UIColor(0x007ee5) : UIColor(0x787878)) diff --git a/TelegramUI/ChatToastAlertPanelNode.swift b/TelegramUI/ChatToastAlertPanelNode.swift new file mode 100644 index 0000000000..982953ad77 --- /dev/null +++ b/TelegramUI/ChatToastAlertPanelNode.swift @@ -0,0 +1,50 @@ +import Foundation +import Display +import AsyncDisplayKit + +final class ChatToastAlertPanelNode: ChatTitleAccessoryPanelNode { + private let separatorNode: ASDisplayNode + private let titleNode: ASTextNode + + var text: String = "" { + didSet { + if self.text != oldValue { + self.titleNode.attributedText = NSAttributedString(string: text, font: Font.regular(14.0), textColor: UIColor.black) + self.setNeedsLayout() + } + } + } + + override init() { + self.separatorNode = ASDisplayNode() + self.separatorNode.backgroundColor = UIColor(red: 0.6953125, green: 0.6953125, blue: 0.6953125, alpha: 1.0) + self.separatorNode.isLayerBacked = true + + self.titleNode = ASTextNode() + self.titleNode.attributedText = NSAttributedString(string: "", font: Font.regular(14.0), textColor: UIColor.black) + self.titleNode.maximumNumberOfLines = 1 + + super.init() + + self.backgroundColor = UIColor(0xF5F6F8) + + self.addSubnode(self.titleNode) + self.addSubnode(self.separatorNode) + } + + override func updateLayout(width: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> CGFloat { + let panelHeight: CGFloat = 40.0 + + transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: panelHeight - UIScreenPixel), size: CGSize(width: width, height: UIScreenPixel))) + self.setNeedsLayout() + + return panelHeight + } + + override func layout() { + super.layout() + + let titleSize = self.titleNode.measure(CGSize(width: self.bounds.size.width - 20.0, height: 100.0)) + self.titleNode.frame = CGRect(origin: CGPoint(x: floor((self.bounds.size.width - titleSize.width) / 2.0), y: floor((self.bounds.size.height - titleSize.height) / 2.0)), size: titleSize) + } +} diff --git a/TelegramUI/CommandChatInputContextPanelNode.swift b/TelegramUI/CommandChatInputContextPanelNode.swift new file mode 100644 index 0000000000..2909561845 --- /dev/null +++ b/TelegramUI/CommandChatInputContextPanelNode.swift @@ -0,0 +1,204 @@ +import Foundation +import AsyncDisplayKit +import Postbox +import TelegramCore +import Display + +private struct CommandChatInputContextPanelEntry: Equatable, Comparable, Identifiable { + let index: Int + let peer: Peer + let command: String + let text: String + + var stableId: Int64 { + return self.peer.id.toInt64() + } + + static func ==(lhs: CommandChatInputContextPanelEntry, rhs: CommandChatInputContextPanelEntry) -> Bool { + return lhs.index == rhs.index && lhs.peer.isEqual(rhs.peer) && lhs.command == rhs.command && lhs.text == rhs.text + } + + static func <(lhs: CommandChatInputContextPanelEntry, rhs: CommandChatInputContextPanelEntry) -> Bool { + return lhs.index < rhs.index + } + + func item(account: Account, peerSelected: @escaping (Peer) -> Void) -> ListViewItem { + return CommandChatInputPanelItem(account: account, peer: self.peer, peerSelected: peerSelected) + } +} + +private struct CommandChatInputContextPanelTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] +} + +private func preparedTransition(from fromEntries: [CommandChatInputContextPanelEntry], to toEntries: [CommandChatInputContextPanelEntry], account: Account, peerSelected: @escaping (Peer) -> Void) -> CommandChatInputContextPanelTransition { + 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: $0.1.item(account: account, peerSelected: peerSelected), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, peerSelected: peerSelected), directionHint: nil) } + + return CommandChatInputContextPanelTransition(deletions: deletions, insertions: insertions, updates: updates) +} + +final class CommandChatInputContextPanelNode: ChatInputContextPanelNode { + private let listView: ListView + private var currentEntries: [CommandChatInputContextPanelEntry]? + + private var enqueuedTransitions: [(CommandChatInputContextPanelTransition, Bool)] = [] + private var hasValidLayout = false + + override init(account: Account) { + self.listView = ListView() + self.listView.isOpaque = false + self.listView.stackFromBottom = true + self.listView.stackFromBottomInsetItemFactor = 3.5 + self.listView.limitHitTestToNodes = true + + super.init(account: account) + + self.isOpaque = false + self.clipsToBounds = true + + self.addSubnode(self.listView) + } + + func updateResults(_ results: [(Peer, BotCommand)]) { + var entries: [CommandChatInputContextPanelEntry] = [] + var index = 0 + for (peer, command) in results { + entries.append(CommandChatInputContextPanelEntry(index: index, peer: peer, command: command.text, text: command.description)) + index += 1 + } + + let firstTime = self.currentEntries == nil + let transition = preparedTransition(from: self.currentEntries ?? [], to: entries, account: self.account, peerSelected: { [weak self] peer in + if let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction { + interfaceInteraction.updateTextInputState { textInputState in + if let (range, type, _) = textInputStateContextQueryRangeAndType(textInputState) { + var inputText = textInputState.inputText + + if let addressName = peer.addressName, !addressName.isEmpty { + let replacementText = addressName + " " + inputText.replaceSubrange(range, with: replacementText) + + let utfLowerIndex = inputText.utf16.distance(from: inputText.utf16.startIndex, to: range.lowerBound.samePosition(in: inputText.utf16)) + + let replacementLength = replacementText.utf16.distance(from: replacementText.utf16.startIndex, to: replacementText.utf16.endIndex) + + let utfUpperPosition = utfLowerIndex + replacementLength + + return ChatTextInputState(inputText: inputText, selectionRange: utfUpperPosition ..< utfUpperPosition) + } + } + return textInputState + } + } + }) + self.currentEntries = entries + self.enqueueTransition(transition, firstTime: firstTime) + } + + private func enqueueTransition(_ transition: CommandChatInputContextPanelTransition, firstTime: Bool) { + enqueuedTransitions.append((transition, firstTime)) + + if self.hasValidLayout { + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransition() + } + } + } + + private func dequeueTransition() { + if let (transition, firstTime) = self.enqueuedTransitions.first { + self.enqueuedTransitions.remove(at: 0) + + var options = ListViewDeleteAndInsertOptions() + if firstTime { + options.insert(.Synchronous) + options.insert(.LowLatency) + } else { + //options.insert(.AnimateInsertion) + } + self.listView.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateOpaqueState: nil, completion: { [weak self] _ in + if let strongSelf = self, firstTime { + var topItemOffset: CGFloat? + strongSelf.listView.forEachItemNode { itemNode in + if topItemOffset == nil { + topItemOffset = itemNode.frame.minY + } + } + + if let topItemOffset = topItemOffset { + let position = strongSelf.listView.layer.position + strongSelf.listView.layer.animatePosition(from: CGPoint(x: position.x, y: position.y + (strongSelf.listView.bounds.size.height - topItemOffset)), to: position, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + } + } + }) + } + } + + override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { + var insets = UIEdgeInsets() + + transition.updateFrame(node: self.listView, frame: CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height)) + + 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 listViewCurve: ListViewAnimationCurve + if curve == 7 { + listViewCurve = .Spring(duration: duration) + } else { + listViewCurve = .Default + } + + let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: size, insets: insets, duration: duration, curve: listViewCurve) + + self.listView.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + + if !hasValidLayout { + hasValidLayout = true + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransition() + } + } + } + + override func animateOut(completion: @escaping () -> Void) { + var topItemOffset: CGFloat? + self.listView.forEachItemNode { itemNode in + if topItemOffset == nil { + topItemOffset = itemNode.frame.minY + } + } + + if let topItemOffset = topItemOffset { + let position = self.listView.layer.position + self.listView.layer.animatePosition(from: position, to: CGPoint(x: position.x, y: position.y + (self.listView.bounds.size.height - topItemOffset)), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in + completion() + }) + } else { + completion() + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let listViewFrame = self.listView.frame + return self.listView.hitTest(CGPoint(x: point.x - listViewFrame.minX, y: point.y - listViewFrame.minY), with: event) + } +} diff --git a/TelegramUI/CommandChatInputPanelItem.swift b/TelegramUI/CommandChatInputPanelItem.swift new file mode 100644 index 0000000000..ee7594ad51 --- /dev/null +++ b/TelegramUI/CommandChatInputPanelItem.swift @@ -0,0 +1,177 @@ +import Foundation +import AsyncDisplayKit +import Display +import TelegramCore +import SwiftSignalKit +import Postbox + +final class CommandChatInputPanelItem: ListViewItem { + fileprivate let account: Account + fileprivate let peer: Peer + private let peerSelected: (Peer) -> Void + + let selectable: Bool = true + + public init(account: Account, peer: Peer, peerSelected: @escaping (Peer) -> Void) { + self.account = account + self.peer = peer + self.peerSelected = peerSelected + } + + public func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> Void) -> Void) { + let configure = { () -> Void in + let node = CommandChatInputPanelItemNode() + + let nodeLayout = node.asyncLayout() + let (top, bottom) = (previousItem != nil, nextItem != nil) + let (layout, apply) = nodeLayout(self, width, top, bottom) + + 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? CommandChatInputPanelItemNode { + Queue.mainQueue().async { + let nodeLayout = node.asyncLayout() + + async { + let (top, bottom) = (previousItem != nil, nextItem != nil) + + let (layout, apply) = nodeLayout(self, width, top, bottom) + Queue.mainQueue().async { + completion(layout, { + apply(animation) + }) + } + } + } + } else { + assertionFailure() + } + } + + func selected(listView: ListView) { + self.peerSelected(self.peer) + } +} + +private let avatarFont = Font.regular(16.0) +private let textFont = Font.medium(14.0) + +final class CommandChatInputPanelItemNode: ListViewItemNode { + static let itemHeight: CGFloat = 42.0 + + private let avatarNode: AvatarNode + private let textNode: TextNode + private let topSeparatorNode: ASDisplayNode + private let separatorNode: ASDisplayNode + private let highlightedBackgroundNode: ASDisplayNode + + init() { + self.avatarNode = AvatarNode(font: avatarFont) + self.textNode = TextNode() + + self.topSeparatorNode = ASDisplayNode() + self.topSeparatorNode.backgroundColor = UIColor(0xC9CDD1) + self.topSeparatorNode.isLayerBacked = true + + self.separatorNode = ASDisplayNode() + self.separatorNode.backgroundColor = UIColor(0xD6D6DA) + self.separatorNode.isLayerBacked = true + + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.backgroundColor = UIColor(0xd9d9d9) + self.highlightedBackgroundNode.isLayerBacked = true + + super.init(layerBacked: false, dynamicBounce: false) + + self.backgroundColor = .white + + self.addSubnode(self.topSeparatorNode) + self.addSubnode(self.separatorNode) + + self.addSubnode(self.avatarNode) + self.addSubnode(self.textNode) + } + + override public func layoutForWidth(_ width: CGFloat, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { + if let item = item as? CommandChatInputPanelItem { + let doLayout = self.asyncLayout() + let merged = (top: previousItem != nil, bottom: nextItem != nil) + let (layout, apply) = doLayout(item, width, merged.top, merged.bottom) + self.contentSize = layout.contentSize + self.insets = layout.insets + apply(.None) + } + } + + func asyncLayout() -> (_ item: CommandChatInputPanelItem, _ width: CGFloat, _ mergedTop: Bool, _ mergedBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { + let makeTextLayout = TextNode.asyncLayout(self.textNode) + return { [weak self] item, width, mergedTop, mergedBottom in + let leftInset: CGFloat = 55.0 + let rightInset: CGFloat = 10.0 + + let (textLayout, textApply) = makeTextLayout(NSAttributedString(string: item.peer.displayTitle, font: textFont, textColor: .black), nil, 1, .end, CGSize(width: width - leftInset - rightInset, height: 100.0), nil) + + let nodeLayout = ListViewItemNodeLayout(contentSize: CGSize(width: width, height: HashtagChatInputPanelItemNode.itemHeight), insets: UIEdgeInsets()) + + return (nodeLayout, { _ in + if let strongSelf = self { + strongSelf.avatarNode.setPeer(account: item.account, peer: item.peer) + + textApply() + + strongSelf.avatarNode.frame = CGRect(origin: CGPoint(x: 12.0, y: floor((nodeLayout.contentSize.height - 30.0) / 2.0)), size: CGSize(width: 30.0, height: 30.0)) + strongSelf.textNode.frame = CGRect(origin: CGPoint(x: leftInset, y: floor((nodeLayout.contentSize.height - textLayout.size.height) / 2.0)), size: textLayout.size) + + strongSelf.topSeparatorNode.isHidden = mergedTop + strongSelf.separatorNode.isHidden = !mergedBottom + + strongSelf.topSeparatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: UIScreenPixel)) + strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: nodeLayout.contentSize.height - UIScreenPixel), size: CGSize(width: width - leftInset, height: UIScreenPixel)) + + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: nodeLayout.size.height + 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/ContactsPeerItem.swift b/TelegramUI/ContactsPeerItem.swift index 2c3d5c06db..1af8b8a800 100644 --- a/TelegramUI/ContactsPeerItem.swift +++ b/TelegramUI/ContactsPeerItem.swift @@ -187,10 +187,6 @@ class ContactsPeerItemNode: ListViewItemNode { super.setHighlighted(highlighted, animated: animated) if highlighted { - /*self.contentNode.displaysAsynchronously = false - self.contentNode.backgroundColor = UIColor.clear - self.contentNode.isOpaque = false*/ - self.highlightedBackgroundNode.alpha = 1.0 if self.highlightedBackgroundNode.supernode == nil { self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode) @@ -202,18 +198,12 @@ class ContactsPeerItemNode: ListViewItemNode { if let strongSelf = self { if completed { strongSelf.highlightedBackgroundNode.removeFromSupernode() - /*strongSelf.contentNode.backgroundColor = UIColor.white - strongSelf.contentNode.isOpaque = true - strongSelf.contentNode.displaysAsynchronously = true*/ } } - }) + }) self.highlightedBackgroundNode.alpha = 0.0 } else { self.highlightedBackgroundNode.removeFromSupernode() - /*self.contentNode.backgroundColor = UIColor.white - self.contentNode.isOpaque = true - self.contentNode.displaysAsynchronously = true*/ } } } diff --git a/TelegramUI/HashtagChatInputContextPanelNode.swift b/TelegramUI/HashtagChatInputContextPanelNode.swift index d641ad0658..06fea0ec62 100644 --- a/TelegramUI/HashtagChatInputContextPanelNode.swift +++ b/TelegramUI/HashtagChatInputContextPanelNode.swift @@ -4,73 +4,203 @@ import Postbox import TelegramCore import Display -final class HashtagChatInputContextPanelNode: ChatInputContextPanelNode, UITableViewDelegate, UITableViewDataSource { - private let tableView: UITableView - private let tableBackgroundView: UIView +private struct HashtagChatInputContextPanelEntry: Equatable, Comparable, Identifiable { + let index: Int + let text: String - private var results: [String] = [] + var stableId: Int { + return self.text.hashValue + } - override init() { - self.tableView = UITableView(frame: CGRect(), style: .plain) - self.tableBackgroundView = UIView() - self.tableBackgroundView.backgroundColor = UIColor.white + static func ==(lhs: HashtagChatInputContextPanelEntry, rhs: HashtagChatInputContextPanelEntry) -> Bool { + return lhs.index == rhs.index && lhs.text == rhs.text + } + + static func <(lhs: HashtagChatInputContextPanelEntry, rhs: HashtagChatInputContextPanelEntry) -> Bool { + return lhs.index < rhs.index + } + + func item(hashtagSelected: @escaping (String) -> Void) -> ListViewItem { + return HashtagChatInputPanelItem(text: self.text, hashtagSelected: hashtagSelected) + } +} + +private struct HashtagChatInputContextPanelTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] +} + +private func preparedTransition(from fromEntries: [HashtagChatInputContextPanelEntry], to toEntries: [HashtagChatInputContextPanelEntry], hashtagSelected: @escaping (String) -> Void) -> HashtagChatInputContextPanelTransition { + 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: $0.1.item(hashtagSelected: hashtagSelected), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(hashtagSelected: hashtagSelected), directionHint: nil) } + + return HashtagChatInputContextPanelTransition(deletions: deletions, insertions: insertions, updates: updates) +} + +final class HashtagChatInputContextPanelNode: ChatInputContextPanelNode { + private let listView: ListView + private var currentEntries: [HashtagChatInputContextPanelEntry]? + + private var enqueuedTransitions: [(HashtagChatInputContextPanelTransition, Bool)] = [] + private var hasValidLayout = false + + override init(account: Account) { + self.listView = ListView() + self.listView.isOpaque = false + self.listView.stackFromBottom = true + self.listView.stackFromBottomInsetItemFactor = 3.5 + self.listView.limitHitTestToNodes = true - super.init() + super.init(account: account) + self.isOpaque = false self.clipsToBounds = true - self.tableView.dataSource = self - self.tableView.delegate = self - self.tableView.rowHeight = 42.0 - self.tableView.showsVerticalScrollIndicator = false - self.tableView.backgroundColor = nil - self.tableView.isOpaque = false + self.addSubnode(self.listView) + } + + func updateResults(_ results: [String]) { + var entries: [HashtagChatInputContextPanelEntry] = [] + var index = 0 + var textSet = Set() + for text in results { + let textHash = text.hashValue + if textSet.contains(textHash) { + continue + } + textSet.insert(textHash) + entries.append(HashtagChatInputContextPanelEntry(index: index, text: text)) + index += 1 + } - self.view.addSubview(self.tableBackgroundView) - self.view.addSubview(self.tableView) + let firstTime = self.currentEntries == nil + let transition = preparedTransition(from: self.currentEntries ?? [], to: entries, hashtagSelected: { [weak self] text in + if let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction { + interfaceInteraction.updateTextInputState { textInputState in + if let (range, type, _) = textInputStateContextQueryRangeAndType(textInputState) { + var inputText = textInputState.inputText + + let replacementText = text + " " + inputText.replaceSubrange(range, with: replacementText) + + let utfLowerIndex = inputText.utf16.distance(from: inputText.utf16.startIndex, to: range.lowerBound.samePosition(in: inputText.utf16)) + + let replacementLength = replacementText.utf16.distance(from: replacementText.utf16.startIndex, to: replacementText.utf16.endIndex) + + let utfUpperPosition = utfLowerIndex + replacementLength + + return ChatTextInputState(inputText: inputText, selectionRange: utfUpperPosition ..< utfUpperPosition) + } + return textInputState + } + } + }) + self.currentEntries = entries + self.enqueueTransition(transition, firstTime: firstTime) + } + + private func enqueueTransition(_ transition: HashtagChatInputContextPanelTransition, firstTime: Bool) { + enqueuedTransitions.append((transition, firstTime)) - self.results = (0 ..< 50).map { "#tag \($0)" } + if self.hasValidLayout { + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransition() + } + } } - func setup(account: Account, peerId: PeerId, query: String) { + private func dequeueTransition() { + if let (transition, firstTime) = self.enqueuedTransitions.first { + self.enqueuedTransitions.remove(at: 0) + + var options = ListViewDeleteAndInsertOptions() + if firstTime { + options.insert(.Synchronous) + options.insert(.LowLatency) + } else { + //options.insert(.AnimateInsertion) + } + self.listView.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateOpaqueState: nil, completion: { [weak self] _ in + if let strongSelf = self, firstTime { + var topItemOffset: CGFloat? + strongSelf.listView.forEachItemNode { itemNode in + if topItemOffset == nil { + topItemOffset = itemNode.frame.minY + } + } + + if let topItemOffset = topItemOffset { + let position = strongSelf.listView.layer.position + strongSelf.listView.layer.animatePosition(from: CGPoint(x: position.x, y: position.y + (strongSelf.listView.bounds.size.height - topItemOffset)), to: position, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + } + } + }) + } } - private func updateTable() { - let itemsHeight = CGFloat(self.results.count) * self.tableView.rowHeight - let minimalDisplayedItemsHeight = floor(self.tableView.rowHeight * 3.5) - let topInset = max(0.0, self.bounds.size.height - min(itemsHeight, minimalDisplayedItemsHeight)) + override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { + var insets = UIEdgeInsets() - self.tableView.contentInset = UIEdgeInsets(top: topInset, left: 0.0, bottom: 0.0, right: 0.0) - self.tableView.contentOffset = CGPoint(x: 0.0, y: -topInset) - self.tableView.setNeedsLayout() - } - - override func updateFrames(transition: ContainedViewLayoutTransition) { - self.tableView.frame = self.bounds - self.updateTable() - } - - override func animateIn() { - self.layer.animateBounds(from: self.layer.bounds.offsetBy(dx: 0.0, dy: -self.layer.bounds.size.height), to: self.layer.bounds, duration: 0.45, timingFunction: kCAMediaTimingFunctionSpring) + transition.updateFrame(node: self.listView, frame: CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height)) + + 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 listViewCurve: ListViewAnimationCurve + if curve == 7 { + listViewCurve = .Spring(duration: duration) + } else { + listViewCurve = .Default + } + + let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: size, insets: insets, duration: duration, curve: listViewCurve) + + self.listView.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + + if !hasValidLayout { + hasValidLayout = true + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransition() + } + } } override func animateOut(completion: @escaping () -> Void) { - self.layer.animateBounds(from: self.layer.bounds, to: self.layer.bounds.offsetBy(dx: 0.0, dy: -self.layer.bounds.size.height), duration: 0.25, timingFunction: kCAMediaTimingFunctionEaseOut, removeOnCompletion: false, completion: { _ in + var topItemOffset: CGFloat? + self.listView.forEachItemNode { itemNode in + if topItemOffset == nil { + topItemOffset = itemNode.frame.minY + } + } + + if let topItemOffset = topItemOffset { + let position = self.listView.layer.position + self.listView.layer.animatePosition(from: position, to: CGPoint(x: position.x, y: position.y + (self.listView.bounds.size.height - topItemOffset)), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in + completion() + }) + } else { completion() - }) + } } - public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return self.results.count - } - - public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - var cell = (tableView.dequeueReusableCell(withIdentifier: "C") as? HashtagsTableCell) ?? HashtagsTableCell() - cell.textLabel?.text = self.results[indexPath.row] - return cell - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - self.tableBackgroundView.frame = CGRect(origin: CGPoint(x: 0.0, y: max(0.0, -self.tableView.contentOffset.y)), size: self.bounds.size) + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let listViewFrame = self.listView.frame + return self.listView.hitTest(CGPoint(x: point.x - listViewFrame.minX, y: point.y - listViewFrame.minY), with: event) } } diff --git a/TelegramUI/HashtagChatInputPanelItem.swift b/TelegramUI/HashtagChatInputPanelItem.swift new file mode 100644 index 0000000000..beaa430d7c --- /dev/null +++ b/TelegramUI/HashtagChatInputPanelItem.swift @@ -0,0 +1,164 @@ +import Foundation +import AsyncDisplayKit +import Display +import TelegramCore +import SwiftSignalKit +import Postbox + +final class HashtagChatInputPanelItem: ListViewItem { + fileprivate let text: String + private let hashtagSelected: (String) -> Void + + let selectable: Bool = true + + public init(text: String, hashtagSelected: @escaping (String) -> Void) { + self.text = text + self.hashtagSelected = hashtagSelected + } + + public func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> Void) -> Void) { + let configure = { () -> Void in + let node = HashtagChatInputPanelItemNode() + + let nodeLayout = node.asyncLayout() + let (top, bottom) = (previousItem != nil, nextItem != nil) + let (layout, apply) = nodeLayout(self, width, top, bottom) + + 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? HashtagChatInputPanelItemNode { + Queue.mainQueue().async { + let nodeLayout = node.asyncLayout() + + async { + let (top, bottom) = (previousItem != nil, nextItem != nil) + + let (layout, apply) = nodeLayout(self, width, top, bottom) + Queue.mainQueue().async { + completion(layout, { + apply(animation) + }) + } + } + } + } else { + assertionFailure() + } + } + + func selected(listView: ListView) { + self.hashtagSelected(self.text) + } +} + +private let textFont = Font.medium(14.0) + +final class HashtagChatInputPanelItemNode: ListViewItemNode { + static let itemHeight: CGFloat = 42.0 + private let textNode: TextNode + private let topSeparatorNode: ASDisplayNode + private let separatorNode: ASDisplayNode + private let highlightedBackgroundNode: ASDisplayNode + + init() { + self.textNode = TextNode() + + self.topSeparatorNode = ASDisplayNode() + self.topSeparatorNode.backgroundColor = UIColor(0xC9CDD1) + self.topSeparatorNode.isLayerBacked = true + + self.separatorNode = ASDisplayNode() + self.separatorNode.backgroundColor = UIColor(0xD6D6DA) + self.separatorNode.isLayerBacked = true + + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.backgroundColor = UIColor(0xd9d9d9) + self.highlightedBackgroundNode.isLayerBacked = true + + super.init(layerBacked: false, dynamicBounce: false) + + self.backgroundColor = .white + self.addSubnode(self.topSeparatorNode) + self.addSubnode(self.separatorNode) + self.addSubnode(self.textNode) + } + + override public func layoutForWidth(_ width: CGFloat, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { + if let item = item as? HashtagChatInputPanelItem { + let doLayout = self.asyncLayout() + let merged = (top: previousItem != nil, bottom: nextItem != nil) + let (layout, apply) = doLayout(item, width, merged.top, merged.bottom) + self.contentSize = layout.contentSize + self.insets = layout.insets + apply(.None) + } + } + + func asyncLayout() -> (_ item: HashtagChatInputPanelItem, _ width: CGFloat, _ mergedTop: Bool, _ mergedBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { + let makeTextLayout = TextNode.asyncLayout(self.textNode) + return { [weak self] item, width, mergedTop, mergedBottom in + let leftInset: CGFloat = 15.0 + let rightInset: CGFloat = 10.0 + + let (textLayout, textApply) = makeTextLayout(NSAttributedString(string: item.text, font: textFont, textColor: .black), nil, 1, .end, CGSize(width: width - leftInset - rightInset, height: 100.0), nil) + + let nodeLayout = ListViewItemNodeLayout(contentSize: CGSize(width: width, height: HashtagChatInputPanelItemNode.itemHeight), insets: UIEdgeInsets()) + + return (nodeLayout, { _ in + if let strongSelf = self { + textApply() + strongSelf.textNode.frame = CGRect(origin: CGPoint(x: leftInset, y: floor((nodeLayout.contentSize.height - textLayout.size.height) / 2.0)), size: textLayout.size) + + strongSelf.topSeparatorNode.isHidden = mergedTop + strongSelf.separatorNode.isHidden = !mergedBottom + + strongSelf.topSeparatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: UIScreenPixel)) + strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: nodeLayout.contentSize.height - UIScreenPixel), size: CGSize(width: width - leftInset, height: UIScreenPixel)) + + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: nodeLayout.size.height + 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/HashtagsTableCell.swift b/TelegramUI/HashtagsTableCell.swift deleted file mode 100644 index 3d61b42434..0000000000 --- a/TelegramUI/HashtagsTableCell.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation -import UIKit - -final class HashtagsTableCell: UITableViewCell { - override init(style: UITableViewCellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/TelegramUI/LegacyAttachmentMenu.swift b/TelegramUI/LegacyAttachmentMenu.swift index 847d7ee085..eaf1705f33 100644 --- a/TelegramUI/LegacyAttachmentMenu.swift +++ b/TelegramUI/LegacyAttachmentMenu.swift @@ -4,7 +4,7 @@ import TelegramLegacyComponents import Display import SwiftSignalKit -func legacyAttachmentMenu(parentController: LegacyController, presentOverlayController: @escaping (UIViewController) -> (() -> Void), openGallery: @escaping () -> Void, openCamera: @escaping (TGAttachmentCameraView?, TGMenuSheetController?) -> Void, sendMessagesWithSignals: @escaping ([Any]?) -> Void) -> TGMenuSheetController { +func legacyAttachmentMenu(parentController: LegacyController, presentOverlayController: @escaping (UIViewController) -> (() -> Void), openGallery: @escaping () -> Void, openCamera: @escaping (TGAttachmentCameraView?, TGMenuSheetController?) -> Void, openFileGallery: @escaping () -> Void, sendMessagesWithSignals: @escaping ([Any]?) -> Void) -> TGMenuSheetController { let controller = TGMenuSheetController() controller.applicationInterface = parentController.applicationInterface controller.dismissesByOutsideTap = true @@ -39,7 +39,9 @@ func legacyAttachmentMenu(parentController: LegacyController, presentOverlayCont }) itemViews.append(galleryItem) - let fileItem = TGMenuSheetButtonItemView(title: "File", type: TGMenuSheetButtonTypeDefault, action: { + let fileItem = TGMenuSheetButtonItemView(title: "File", type: TGMenuSheetButtonTypeDefault, action: {[weak controller] in + controller?.dismiss(animated: true) + openFileGallery() }) itemViews.append(fileItem) @@ -257,3 +259,7 @@ func legacyAttachmentMenu(parentController: LegacyController, presentOverlayCont [self.view endEditing:true]; [controller presentInViewController:self sourceView:_inputTextPanel.attachButton animated:true];*/ } + +func legacyFileAttachmentMenu(menuSheetController: TGMenuSheetController) { + +} diff --git a/TelegramUI/LegacyController.swift b/TelegramUI/LegacyController.swift index cdcb0f9cbf..55c0c0e4af 100644 --- a/TelegramUI/LegacyController.swift +++ b/TelegramUI/LegacyController.swift @@ -162,13 +162,23 @@ class LegacyController: ViewController { self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationBar.frame.maxY, transition: transition) } - func dismiss() { + public func dismiss() { switch self.presentation { case .modal: self.controllerNode.animateModalOut { [weak self] in + if let controller = self?.legacyController as? TGViewController { + controller.didDismiss() + } else if let controller = self?.legacyController as? TGNavigationController { + controller.didDismiss() + } self?.presentingViewController?.dismiss(animated: false, completion: nil) } case .custom: + if let controller = self.legacyController as? TGViewController { + controller.didDismiss() + } else if let controller = self.legacyController as? TGNavigationController { + controller.didDismiss() + } self.presentingViewController?.dismiss(animated: false, completion: nil) } } diff --git a/TelegramUI/LegacyLocationPicker.swift b/TelegramUI/LegacyLocationPicker.swift new file mode 100644 index 0000000000..fbf287572c --- /dev/null +++ b/TelegramUI/LegacyLocationPicker.swift @@ -0,0 +1,2 @@ +import Foundation + diff --git a/TelegramUI/LegacyMediaPickers.swift b/TelegramUI/LegacyMediaPickers.swift index e4c2b8108c..ea12e85c98 100644 --- a/TelegramUI/LegacyMediaPickers.swift +++ b/TelegramUI/LegacyMediaPickers.swift @@ -19,9 +19,9 @@ func configureLegacyAssetPicker(_ controller: TGMediaAssetsController, captionsE controller.shouldShowFileTipIfNeeded = showFileTooltip } -func legacyAssetPicker() -> Signal<(@escaping (UIViewController) -> (() -> Void)) -> TGMediaAssetsController, NoError> { +func legacyAssetPicker(fileMode: Bool) -> Signal<(@escaping (UIViewController) -> (() -> Void)) -> TGMediaAssetsController, NoError> { return Signal { subscriber in - let intent = TGMediaAssetsControllerSendMediaIntent + let intent = fileMode ? TGMediaAssetsControllerSendFileIntent : TGMediaAssetsControllerSendMediaIntent if TGMediaAssetsLibrary.authorizationStatus() == TGMediaLibraryAuthorizationStatusNotDetermined { TGMediaAssetsLibrary.requestAuthorization(for: TGMediaAssetAnyType, completion: { (status, group) in @@ -55,9 +55,15 @@ func legacyAssetPicker() -> Signal<(@escaping (UIViewController) -> (() -> Void) } } -private enum LegacyAssetItem { +private enum LegacyAssetData { case image(UIImage) case asset(PHAsset) + case tempFile(String) +} + +private enum LegacyAssetItem { + case image(LegacyAssetData) + case file(LegacyAssetData, mimeType: String, name: String) } private final class LegacyAssetItemWrapper: NSObject { @@ -76,13 +82,37 @@ func legacyAssetPickerItemGenerator() -> ((Any?, String?, String?) -> [AnyHashab if (dict["type"] as! NSString) == "editedPhoto" || (dict["type"] as! NSString) == "capturedPhoto" { let image = dict["image"] as! UIImage var result: [AnyHashable : Any] = [:] - result["item" as NSString] = LegacyAssetItemWrapper(item: .image(image)) + result["item" as NSString] = LegacyAssetItemWrapper(item: .image(.image(image))) return result } else if (dict["type"] as! NSString) == "cloudPhoto" { let asset = dict["asset"] as! TGMediaAsset + var asFile = false + if let document = dict["document"] as? NSNumber, document.boolValue { + asFile = true + } var result: [AnyHashable : Any] = [:] - result["item" as NSString] = LegacyAssetItemWrapper(item: .asset(asset.backingAsset)) + if asFile { + //result["item" as NSString] = LegacyAssetItemWrapper(item: .file(.asset(asset.backingAsset))) + return nil + } else { + result["item" as NSString] = LegacyAssetItemWrapper(item: .image(.asset(asset.backingAsset))) + } return result + } else if (dict["type"] as! NSString) == "file" { + if let tempFileUrl = dict["tempFileUrl"] as? URL { + var mimeType = "application/binary" + if let customMimeType = dict["mimeType"] as? String { + mimeType = customMimeType + } + var name = "file" + if let customName = dict["fileName"] as? String { + name = customName + } + + var result: [AnyHashable : Any] = [:] + result["item" as NSString] = LegacyAssetItemWrapper(item: .file(.tempFile(tempFileUrl.path), mimeType: mimeType, name: name)) + return result + } } return nil } @@ -96,30 +126,46 @@ func legacyAssetPickerEnqueueMessages(account: Account, peerId: PeerId, signals: for item in (anyValues as! NSArray) { if let item = (item as? NSDictionary)?.object(forKey: "item") as? LegacyAssetItemWrapper { switch item.item { - case let .image(image): - var randomId: Int64 = 0 - arc4random_buf(&randomId, 8) - let tempFilePath = NSTemporaryDirectory() + "\(randomId).jpeg" - let scaledSize = image.size.aspectFitted(CGSize(width: 1280.0, height: 1280.0)) - if let scaledImage = generateImage(scaledSize, contextGenerator: { size, context in - context.draw(image.cgImage!, in: CGRect(origin: CGPoint(), size: size)) - }, opaque: true) { - if let scaledImageData = UIImageJPEGRepresentation(image, 0.52) { - let _ = try? scaledImageData.write(to: URL(fileURLWithPath: tempFilePath)) - let resource = LocalFileReferenceMediaResource(localFilePath: tempFilePath, randomId: randomId) + case let .image(data): + switch data { + case let .image(image): + var randomId: Int64 = 0 + arc4random_buf(&randomId, 8) + let tempFilePath = NSTemporaryDirectory() + "\(randomId).jpeg" + let scaledSize = image.size.aspectFitted(CGSize(width: 1280.0, height: 1280.0)) + if let scaledImage = generateImage(scaledSize, contextGenerator: { size, context in + context.draw(image.cgImage!, in: CGRect(origin: CGPoint(), size: size)) + }, opaque: true) { + if let scaledImageData = UIImageJPEGRepresentation(image, 0.52) { + let _ = try? scaledImageData.write(to: URL(fileURLWithPath: tempFilePath)) + let resource = LocalFileReferenceMediaResource(localFilePath: tempFilePath, randomId: randomId) + let media = TelegramMediaImage(imageId: MediaId(namespace: Namespaces.Media.LocalImage, id: randomId), representations: [TelegramMediaImageRepresentation(dimensions: scaledSize, resource: resource)]) + messages.append(.message(text: "", attributes: [], media: media, replyToMessageId: nil)) + } + } + case let .asset(asset): + var randomId: Int64 = 0 + arc4random_buf(&randomId, 8) + let size = CGSize(width: CGFloat(asset.pixelWidth), height: CGFloat(asset.pixelHeight)) + let scaledSize = size.aspectFitted(CGSize(width: 1280.0, height: 1280.0)) + let resource = PhotoLibraryMediaResource(localIdentifier: asset.localIdentifier) + let media = TelegramMediaImage(imageId: MediaId(namespace: Namespaces.Media.LocalImage, id: randomId), representations: [TelegramMediaImageRepresentation(dimensions: scaledSize, resource: resource)]) - messages.append(.message(text: "", media: media, replyToMessageId: nil)) - } + messages.append(.message(text: "", attributes: [], media: media, replyToMessageId: nil)) + case .tempFile: + break + } + case let .file(data, mimeType, name): + switch data { + case let .tempFile(path): + var randomId: Int64 = 0 + arc4random_buf(&randomId, 8) + let resource = LocalFileReferenceMediaResource(localFilePath: path, randomId: randomId) + let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: randomId), resource: resource, previewRepresentations: [], mimeType: mimeType, size: nil, attributes: [.FileName(fileName: name)]) + messages.append(.message(text: "", attributes: [], media: media, replyToMessageId: nil)) + default: + break } - case let .asset(asset): - var randomId: Int64 = 0 - arc4random_buf(&randomId, 8) - let size = CGSize(width: CGFloat(asset.pixelWidth), height: CGFloat(asset.pixelHeight)) - let scaledSize = size.aspectFitted(CGSize(width: 1280.0, height: 1280.0)) - let resource = PhotoLibraryMediaResource(localIdentifier: asset.localIdentifier) - - let media = TelegramMediaImage(imageId: MediaId(namespace: Namespaces.Media.LocalImage, id: randomId), representations: [TelegramMediaImageRepresentation(dimensions: scaledSize, resource: resource)]) - messages.append(.message(text: "", media: media, replyToMessageId: nil)) } } } diff --git a/TelegramUI/ListMessageFileItemNode.swift b/TelegramUI/ListMessageFileItemNode.swift index e025785495..7c6255877a 100644 --- a/TelegramUI/ListMessageFileItemNode.swift +++ b/TelegramUI/ListMessageFileItemNode.swift @@ -239,7 +239,7 @@ final class ListMessageFileItemNode: ListMessageNode { var extensionText: NSAttributedString? var iconImageRepresentation: TelegramMediaImageRepresentation? - var updateIconImageSignal: Signal<(TransformImageArguments) -> DrawingContext, NoError>? + var updateIconImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? var updatedStatusSignal: Signal? var updatedFetchControls: FetchControls? @@ -267,7 +267,14 @@ final class ListMessageFileItemNode: ListMessageNode { let dateString = dateFormatter.string(from: Date(timeIntervalSince1970: Double(item.message.timestamp))) - descriptionText = NSAttributedString(string: "\(dataSizeString(file.size)) • \(dateString)", font: descriptionFont, textColor: UIColor(0xa8a8a8)) + let descriptionString: String + if let size = file.size { + descriptionString = "\(dataSizeString(size)) • \(dateString)" + } else { + descriptionString = "\(dateString)" + } + + descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: UIColor(0xa8a8a8)) break } diff --git a/TelegramUI/ListMessageHoleItem.swift b/TelegramUI/ListMessageHoleItem.swift new file mode 100644 index 0000000000..cb5a85c3cc --- /dev/null +++ b/TelegramUI/ListMessageHoleItem.swift @@ -0,0 +1,112 @@ +import Foundation +import AsyncDisplayKit +import Display +import TelegramCore +import SwiftSignalKit +import Postbox + +final class ListMessageHoleItem: ListViewItem { + public init() { + } + + public func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> Void) -> Void) { + let configure = { () -> Void in + let node = ListMessageHoleItemNode() + + let nodeLayout = node.asyncLayout() + let (top, bottom, dateAtBottom) = (false, false, false) + let (layout, apply) = nodeLayout(self, width, top, bottom, dateAtBottom) + + 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? ListMessageHoleItemNode { + Queue.mainQueue().async { + node.updateSelectionState(animated: false) + + let nodeLayout = node.asyncLayout() + + async { + let (top, bottom, dateAtBottom) = (false, false, false) //self.mergedWithItems(top: previousItem, bottom: nextItem) + + let (layout, apply) = nodeLayout(self, width, top, bottom, dateAtBottom) + Queue.mainQueue().async { + completion(layout, { + apply(animation) + }) + } + } + } + } else { + assertionFailure() + } + } +} + +final class ListMessageHoleItemNode: ListViewItemNode { + private var activityIndicator: UIActivityIndicatorView? + + init() { + super.init(layerBacked: false, dynamicBounce: false) + } + + override func didLoad() { + super.didLoad() + + let activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray) + self.activityIndicator = activityIndicator + self.view.addSubview(activityIndicator) + let size = activityIndicator.bounds.size + activityIndicator.frame = CGRect(origin: CGPoint(x: floor((self.bounds.size.width - size.width) / 2.0), y: floor((self.bounds.size.height - size.height) / 2.0)), size: size) + activityIndicator.startAnimating() + } + + override public func layoutForWidth(_ width: CGFloat, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { + if let item = item as? ListMessageHoleItem { + let doLayout = self.asyncLayout() + let merged = (top: false, bottom: false, dateAtBottom: false)//item.mergedWithItems(top: previousItem, bottom: nextItem) + let (layout, apply) = doLayout(item, width, merged.top, merged.bottom, merged.dateAtBottom) + self.contentSize = layout.contentSize + self.insets = layout.insets + apply(.None) + } + } + + func asyncLayout() -> (_ item: ListMessageHoleItem, _ width: CGFloat, _ mergedTop: Bool, _ mergedBottom: Bool, _ dateAtBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { + return { [weak self] _, width, _, _, _ in + return (ListViewItemNodeLayout(contentSize: CGSize(width: width, height: 50.0), insets: UIEdgeInsets()), { _ in + if let strongSelf = self, let activityIndicator = strongSelf.activityIndicator { + let boundsSize = CGSize(width: width, height: 50.0) + let size = activityIndicator.bounds.size + activityIndicator.frame = CGRect(origin: CGPoint(x: floor((boundsSize.width - size.width) / 2.0), y: floor((boundsSize.height - size.height) / 2.0)), size: size) + } + }) + } + } + + func transitionNode(id: MessageId, media: Media) -> ASDisplayNode? { + return nil + } + + func updateHiddenMedia() { + } + + func updateSelectionState(animated: Bool) { + } +} diff --git a/TelegramUI/ListMessageItem.swift b/TelegramUI/ListMessageItem.swift index c610101d5c..c34ea33d2e 100644 --- a/TelegramUI/ListMessageItem.swift +++ b/TelegramUI/ListMessageItem.swift @@ -58,7 +58,7 @@ final class ListMessageItem: ListViewItem { } 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 { + if let node = node as? ListMessageNode { Queue.mainQueue().async { node.setupItem(self) @@ -77,6 +77,8 @@ final class ListMessageItem: ListViewItem { } } } + } else { + assertionFailure() } } diff --git a/TelegramUI/ListMessageSnippetItemNode.swift b/TelegramUI/ListMessageSnippetItemNode.swift index 815009ea82..921e3f2d1c 100644 --- a/TelegramUI/ListMessageSnippetItemNode.swift +++ b/TelegramUI/ListMessageSnippetItemNode.swift @@ -105,7 +105,7 @@ final class ListMessageSnippetItemNode: ListMessageNode { var iconText: NSAttributedString? var iconImageRepresentation: TelegramMediaImageRepresentation? - var updateIconImageSignal: Signal<(TransformImageArguments) -> DrawingContext, NoError>? + var updateIconImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? let applyIconTextBackgroundImage = iconTextBackgroundImage diff --git a/TelegramUI/MentionChatInputContextPanelNode.swift b/TelegramUI/MentionChatInputContextPanelNode.swift index 43f0663b92..edc99845da 100644 --- a/TelegramUI/MentionChatInputContextPanelNode.swift +++ b/TelegramUI/MentionChatInputContextPanelNode.swift @@ -3,105 +3,206 @@ import AsyncDisplayKit import Postbox import TelegramCore import Display -import SwiftSignalKit -final class MentionChatInputContextPanelNode: ChatInputContextPanelNode, UITableViewDelegate, UITableViewDataSource { - private let tableView: UITableView - private let tableBackgroundView: UIView +private struct MentionChatInputContextPanelEntry: Equatable, Comparable, Identifiable { + let index: Int + let peer: Peer - private var account: Account? - private var results: [Peer] = [] + var stableId: Int64 { + return self.peer.id.toInt64() + } - private let disposable = MetaDisposable() + static func ==(lhs: MentionChatInputContextPanelEntry, rhs: MentionChatInputContextPanelEntry) -> Bool { + return lhs.index == rhs.index && lhs.peer.isEqual(rhs.peer) + } - override init() { - self.tableView = UITableView(frame: CGRect(), style: .plain) - self.tableBackgroundView = UIView() - self.tableBackgroundView.backgroundColor = UIColor.white + static func <(lhs: MentionChatInputContextPanelEntry, rhs: MentionChatInputContextPanelEntry) -> Bool { + return lhs.index < rhs.index + } + + func item(account: Account, peerSelected: @escaping (Peer) -> Void) -> ListViewItem { + return MentionChatInputPanelItem(account: account, peer: self.peer, peerSelected: peerSelected) + } +} + +private struct CommandChatInputContextPanelTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] +} + +private func preparedTransition(from fromEntries: [MentionChatInputContextPanelEntry], to toEntries: [MentionChatInputContextPanelEntry], account: Account, peerSelected: @escaping (Peer) -> Void) -> CommandChatInputContextPanelTransition { + 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: $0.1.item(account: account, peerSelected: peerSelected), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, peerSelected: peerSelected), directionHint: nil) } + + return CommandChatInputContextPanelTransition(deletions: deletions, insertions: insertions, updates: updates) +} + +final class MentionChatInputContextPanelNode: ChatInputContextPanelNode { + private let listView: ListView + private var currentEntries: [MentionChatInputContextPanelEntry]? + + private var enqueuedTransitions: [(CommandChatInputContextPanelTransition, Bool)] = [] + private var hasValidLayout = false + + override init(account: Account) { + self.listView = ListView() + self.listView.isOpaque = false + self.listView.stackFromBottom = true + self.listView.stackFromBottomInsetItemFactor = 3.5 + self.listView.limitHitTestToNodes = true - super.init() + super.init(account: account) + self.isOpaque = false self.clipsToBounds = true - self.tableView.dataSource = self - self.tableView.delegate = self - self.tableView.rowHeight = 42.0 - self.tableView.showsVerticalScrollIndicator = false - self.tableView.backgroundColor = nil - self.tableView.isOpaque = false - self.tableView.separatorInset = UIEdgeInsets(top: 0.0, left: 61.0, bottom: 0.0, right: 0.0) - - self.view.addSubview(self.tableBackgroundView) - self.view.addSubview(self.tableView) + self.addSubnode(self.listView) } - deinit { - self.disposable.dispose() - } - - func setup(account: Account, peerId: PeerId, query: String) { - self.account = account - let signal = peerParticipants(account: account, id: peerId) - |> deliverOnMainQueue - - self.disposable.set(signal.start(next: { [weak self] peers in - if let strongSelf = self { - strongSelf.results = peers - strongSelf.tableView.reloadData() - strongSelf.updateTable(animated: true) + func updateResults(_ results: [Peer]) { + var entries: [MentionChatInputContextPanelEntry] = [] + var index = 0 + var peerIdSet = Set() + for peer in results { + let peerId = peer.id.toInt64() + if peerIdSet.contains(peerId) { + continue } - })) - } - - private func updateTable(animated: Bool = false) { - let itemsHeight = CGFloat(self.results.count) * self.tableView.rowHeight - let minimalDisplayedItemsHeight = floor(self.tableView.rowHeight * 3.5) - let topInset = max(0.0, self.bounds.size.height - min(itemsHeight, minimalDisplayedItemsHeight)) - - if animated { - self.layer.animateBounds(from: self.layer.bounds.offsetBy(dx: 0.0, dy: -self.layer.bounds.size.height), to: self.layer.bounds, duration: 0.45, timingFunction: kCAMediaTimingFunctionSpring) + peerIdSet.insert(peerId) + entries.append(MentionChatInputContextPanelEntry(index: index, peer: peer)) + index += 1 } - self.tableView.contentInset = UIEdgeInsets(top: topInset, left: 0.0, bottom: 0.0, right: 0.0) - self.tableView.contentOffset = CGPoint(x: 0.0, y: -topInset) - self.tableView.setNeedsLayout() + let firstTime = self.currentEntries == nil + let transition = preparedTransition(from: self.currentEntries ?? [], to: entries, account: self.account, peerSelected: { [weak self] peer in + if let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction { + interfaceInteraction.updateTextInputState { textInputState in + if let (range, type, _) = textInputStateContextQueryRangeAndType(textInputState) { + var inputText = textInputState.inputText + + if let addressName = peer.addressName, !addressName.isEmpty { + let replacementText = addressName + " " + inputText.replaceSubrange(range, with: replacementText) + + let utfLowerIndex = inputText.utf16.distance(from: inputText.utf16.startIndex, to: range.lowerBound.samePosition(in: inputText.utf16)) + + let replacementLength = replacementText.utf16.distance(from: replacementText.utf16.startIndex, to: replacementText.utf16.endIndex) + + let utfUpperPosition = utfLowerIndex + replacementLength + + return ChatTextInputState(inputText: inputText, selectionRange: utfUpperPosition ..< utfUpperPosition) + } + } + return textInputState + } + } + }) + self.currentEntries = entries + self.enqueueTransition(transition, firstTime: firstTime) } - override func updateFrames(transition: ContainedViewLayoutTransition) { - self.tableView.frame = self.bounds - self.updateTable() + private func enqueueTransition(_ transition: CommandChatInputContextPanelTransition, firstTime: Bool) { + enqueuedTransitions.append((transition, firstTime)) + + if self.hasValidLayout { + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransition() + } + } } - override func animateIn() { - self.layer.animateBounds(from: self.layer.bounds.offsetBy(dx: 0.0, dy: -self.layer.bounds.size.height), to: self.layer.bounds, duration: 0.45, timingFunction: kCAMediaTimingFunctionSpring) + private func dequeueTransition() { + if let (transition, firstTime) = self.enqueuedTransitions.first { + self.enqueuedTransitions.remove(at: 0) + + var options = ListViewDeleteAndInsertOptions() + if firstTime { + options.insert(.Synchronous) + options.insert(.LowLatency) + } else { + //options.insert(.AnimateInsertion) + } + self.listView.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateOpaqueState: nil, completion: { [weak self] _ in + if let strongSelf = self, firstTime { + var topItemOffset: CGFloat? + strongSelf.listView.forEachItemNode { itemNode in + if topItemOffset == nil { + topItemOffset = itemNode.frame.minY + } + } + + if let topItemOffset = topItemOffset { + let position = strongSelf.listView.layer.position + strongSelf.listView.layer.animatePosition(from: CGPoint(x: position.x, y: position.y + (strongSelf.listView.bounds.size.height - topItemOffset)), to: position, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + } + } + }) + } + } + + override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { + var insets = UIEdgeInsets() + + transition.updateFrame(node: self.listView, frame: CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height)) + + 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 listViewCurve: ListViewAnimationCurve + if curve == 7 { + listViewCurve = .Spring(duration: duration) + } else { + listViewCurve = .Default + } + + let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: size, insets: insets, duration: duration, curve: listViewCurve) + + self.listView.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + + if !hasValidLayout { + hasValidLayout = true + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransition() + } + } } override func animateOut(completion: @escaping () -> Void) { - self.layer.animateBounds(from: self.layer.bounds, to: self.layer.bounds.offsetBy(dx: 0.0, dy: -self.layer.bounds.size.height), duration: 0.25, timingFunction: kCAMediaTimingFunctionEaseOut, removeOnCompletion: false, completion: { _ in + var topItemOffset: CGFloat? + self.listView.forEachItemNode { itemNode in + if topItemOffset == nil { + topItemOffset = itemNode.frame.minY + } + } + + if let topItemOffset = topItemOffset { + let position = self.listView.layer.position + self.listView.layer.animatePosition(from: position, to: CGPoint(x: position.x, y: position.y + (self.listView.bounds.size.height - topItemOffset)), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in + completion() + }) + } else { completion() - }) - } - - public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return self.results.count - } - - public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - var cell = (tableView.dequeueReusableCell(withIdentifier: "C") as? MentionsTableCell) ?? MentionsTableCell() - if let account = self.account { - cell.setupPeer(account: account, peer: self.results[indexPath.row]) } - return cell } - func scrollViewDidScroll(_ scrollView: UIScrollView) { - self.tableBackgroundView.frame = CGRect(origin: CGPoint(x: 0.0, y: max(0.0, -self.tableView.contentOffset.y)), size: self.bounds.size) - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if let addressName = self.results[indexPath.row].addressName { - let string = "@" + addressName + " " - self.interfaceInteraction?.updateTextInputState(ChatTextInputState(inputText: string, selectionRange: string.characters.count ..< string.characters.count)) - } + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let listViewFrame = self.listView.frame + return self.listView.hitTest(CGPoint(x: point.x - listViewFrame.minX, y: point.y - listViewFrame.minY), with: event) } } diff --git a/TelegramUI/MentionChatInputPanelItem.swift b/TelegramUI/MentionChatInputPanelItem.swift new file mode 100644 index 0000000000..3259d016d1 --- /dev/null +++ b/TelegramUI/MentionChatInputPanelItem.swift @@ -0,0 +1,177 @@ +import Foundation +import AsyncDisplayKit +import Display +import TelegramCore +import SwiftSignalKit +import Postbox + +final class MentionChatInputPanelItem: ListViewItem { + fileprivate let account: Account + fileprivate let peer: Peer + private let peerSelected: (Peer) -> Void + + let selectable: Bool = true + + public init(account: Account, peer: Peer, peerSelected: @escaping (Peer) -> Void) { + self.account = account + self.peer = peer + self.peerSelected = peerSelected + } + + public func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> Void) -> Void) { + let configure = { () -> Void in + let node = MentionChatInputPanelItemNode() + + let nodeLayout = node.asyncLayout() + let (top, bottom) = (previousItem != nil, nextItem != nil) + let (layout, apply) = nodeLayout(self, width, top, bottom) + + 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? MentionChatInputPanelItemNode { + Queue.mainQueue().async { + let nodeLayout = node.asyncLayout() + + async { + let (top, bottom) = (previousItem != nil, nextItem != nil) + + let (layout, apply) = nodeLayout(self, width, top, bottom) + Queue.mainQueue().async { + completion(layout, { + apply(animation) + }) + } + } + } + } else { + assertionFailure() + } + } + + func selected(listView: ListView) { + self.peerSelected(self.peer) + } +} + +private let avatarFont = Font.regular(16.0) +private let textFont = Font.medium(14.0) + +final class MentionChatInputPanelItemNode: ListViewItemNode { + static let itemHeight: CGFloat = 42.0 + + private let avatarNode: AvatarNode + private let textNode: TextNode + private let topSeparatorNode: ASDisplayNode + private let separatorNode: ASDisplayNode + private let highlightedBackgroundNode: ASDisplayNode + + init() { + self.avatarNode = AvatarNode(font: avatarFont) + self.textNode = TextNode() + + self.topSeparatorNode = ASDisplayNode() + self.topSeparatorNode.backgroundColor = UIColor(0xC9CDD1) + self.topSeparatorNode.isLayerBacked = true + + self.separatorNode = ASDisplayNode() + self.separatorNode.backgroundColor = UIColor(0xD6D6DA) + self.separatorNode.isLayerBacked = true + + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.backgroundColor = UIColor(0xd9d9d9) + self.highlightedBackgroundNode.isLayerBacked = true + + super.init(layerBacked: false, dynamicBounce: false) + + self.backgroundColor = .white + + self.addSubnode(self.topSeparatorNode) + self.addSubnode(self.separatorNode) + + self.addSubnode(self.avatarNode) + self.addSubnode(self.textNode) + } + + override public func layoutForWidth(_ width: CGFloat, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { + if let item = item as? MentionChatInputPanelItem { + let doLayout = self.asyncLayout() + let merged = (top: previousItem != nil, bottom: nextItem != nil) + let (layout, apply) = doLayout(item, width, merged.top, merged.bottom) + self.contentSize = layout.contentSize + self.insets = layout.insets + apply(.None) + } + } + + func asyncLayout() -> (_ item: MentionChatInputPanelItem, _ width: CGFloat, _ mergedTop: Bool, _ mergedBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { + let makeTextLayout = TextNode.asyncLayout(self.textNode) + return { [weak self] item, width, mergedTop, mergedBottom in + let leftInset: CGFloat = 55.0 + let rightInset: CGFloat = 10.0 + + let (textLayout, textApply) = makeTextLayout(NSAttributedString(string: item.peer.displayTitle, font: textFont, textColor: .black), nil, 1, .end, CGSize(width: width - leftInset - rightInset, height: 100.0), nil) + + let nodeLayout = ListViewItemNodeLayout(contentSize: CGSize(width: width, height: HashtagChatInputPanelItemNode.itemHeight), insets: UIEdgeInsets()) + + return (nodeLayout, { _ in + if let strongSelf = self { + strongSelf.avatarNode.setPeer(account: item.account, peer: item.peer) + + textApply() + + strongSelf.avatarNode.frame = CGRect(origin: CGPoint(x: 12.0, y: floor((nodeLayout.contentSize.height - 30.0) / 2.0)), size: CGSize(width: 30.0, height: 30.0)) + strongSelf.textNode.frame = CGRect(origin: CGPoint(x: leftInset, y: floor((nodeLayout.contentSize.height - textLayout.size.height) / 2.0)), size: textLayout.size) + + strongSelf.topSeparatorNode.isHidden = mergedTop + strongSelf.separatorNode.isHidden = !mergedBottom + + strongSelf.topSeparatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: UIScreenPixel)) + strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: nodeLayout.contentSize.height - UIScreenPixel), size: CGSize(width: width - leftInset, height: UIScreenPixel)) + + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: nodeLayout.size.height + 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/MentionsTableCell.swift b/TelegramUI/MentionsTableCell.swift deleted file mode 100644 index e3b35541c0..0000000000 --- a/TelegramUI/MentionsTableCell.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation -import UIKit -import Display -import Postbox -import TelegramCore - -final class MentionsTableCell: UITableViewCell { - private let avatarNode = AvatarNode(font: Font.regular(16.0)) - private let labelNode = TextNode() - private var peer: Peer? - - override init(style: UITableViewCellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - self.contentView.addSubnode(self.avatarNode) - self.contentView.addSubnode(self.labelNode) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func setupPeer(account: Account, peer: Peer) { - self.peer = peer - self.avatarNode.setPeer(account: account, peer: peer) - self.setNeedsLayout() - } - - override func layoutSubviews() { - super.layoutSubviews() - - self.avatarNode.frame = CGRect(origin: CGPoint(x: 16.0, y: floor((self.bounds.size.height - 30.0) / 2.0)), size: CGSize(width: 30.0, height: 30.0)) - if let peer = self.peer { - let makeLayout = TextNode.asyncLayout(self.labelNode) - let string = NSMutableAttributedString() - string.append(NSAttributedString(string: peer.displayTitle, font: Font.medium(15.0), textColor: UIColor.black)) - if let addressName = peer.addressName { - string.append(NSAttributedString(string: " @" + addressName, font: Font.regular(15.0), textColor: UIColor(0x9099a2))) - } - let (layout, apply) = makeLayout(string, nil, 1, .end, CGSize(width: self.bounds.size.width - 61.0 - 10.0, height: self.bounds.size.height), nil) - self.labelNode.frame = CGRect(origin: CGPoint(x: 61.0, y: floor((self.bounds.size.height - layout.size.height) / 2.0) + 2.0), size: layout.size) - let _ = apply() - } - } -} diff --git a/TelegramUI/NavigationAccessoryPanelNode.swift b/TelegramUI/NavigationAccessoryPanelNode.swift deleted file mode 100644 index cad6b9f354..0000000000 --- a/TelegramUI/NavigationAccessoryPanelNode.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation -import AsyncDisplayKit - -class NavigationAccessoryPanelNode: ASDisplayNode { -} diff --git a/TelegramUI/PeerMediaCollectionController.swift b/TelegramUI/PeerMediaCollectionController.swift index cf47295f66..a7d380687d 100644 --- a/TelegramUI/PeerMediaCollectionController.swift +++ b/TelegramUI/PeerMediaCollectionController.swift @@ -128,7 +128,9 @@ public class PeerMediaCollectionController: ViewController { } }, openPeer: { [weak self] id, navigation in if let strongSelf = self { - (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: id, messageId: nil)) + if let id = id { + (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: id, messageId: nil)) + } } }, openPeerMention: { _ in }, openMessageContextMenu: { [weak self] id, node, frame in @@ -188,13 +190,18 @@ public class PeerMediaCollectionController: ViewController { self.controllerInteraction = controllerInteraction - self.interfaceInteraction = ChatPanelInterfaceInteraction(setupReplyMessage: { _ in }, setupEditMessage: { _ in }, beginMessageSelection: { _ in }, deleteSelectedMessages: { - + self.interfaceInteraction = ChatPanelInterfaceInteraction(setupReplyMessage: { _ in + }, setupEditMessage: { _ in + }, beginMessageSelection: { _ in + }, deleteSelectedMessages: { }, forwardSelectedMessages: { - }, updateTextInputState: { _ in }, updateInputMode: { _ in }, editMessage: { _, _ in + }, beginMessageSearch: { + }, openPeerInfo: { + }, togglePeerNotifications: { + }, sendContextResult: { _ in }, statuses: nil) self.updateInterfaceState(animated: false, { return $0 }) diff --git a/TelegramUI/PhotoResources.swift b/TelegramUI/PhotoResources.swift index 148b0652b4..2c53e9cdc5 100644 --- a/TelegramUI/PhotoResources.swift +++ b/TelegramUI/PhotoResources.swift @@ -382,7 +382,7 @@ private func addCorners(_ context: DrawingContext, arguments: TransformImageArgu } } -func chatMessagePhoto(account: Account, photo: TelegramMediaImage) -> Signal<(TransformImageArguments) -> DrawingContext, NoError> { +func chatMessagePhoto(account: Account, photo: TelegramMediaImage) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { let signal = chatMessagePhotoDatas(account: account, photo: photo) return signal |> map { (thumbnailData, fullSizeData, fullSizeComplete) in @@ -471,7 +471,7 @@ func chatMessagePhoto(account: Account, photo: TelegramMediaImage) -> Signal<(Tr } } -func mediaGridMessagePhoto(account: Account, photo: TelegramMediaImage) -> Signal<(TransformImageArguments) -> DrawingContext, NoError> { +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, fullSizeComplete) in @@ -594,48 +594,48 @@ func chatWebpageSnippetPhotoData(account: Account, photo: TelegramMediaImage) -> } } -func chatWebpageSnippetPhoto(account: Account, photo: TelegramMediaImage) -> Signal<(TransformImageArguments) -> DrawingContext, NoError> { +func chatWebpageSnippetPhoto(account: Account, photo: TelegramMediaImage) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { let signal = chatWebpageSnippetPhotoData(account: account, photo: photo) return signal |> map { fullSizeData 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 { 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) { + if let imageSource = CGImageSourceCreateWithData(fullSizeData as CFData, nil), let image = CGImageSourceCreateImageAtIndex(imageSource, 0, options as CFDictionary) { fullSizeImage = image } } - context.withFlippedContext { c in - c.setBlendMode(.copy) - if arguments.boundingSize.width > arguments.imageSize.width || arguments.boundingSize.height > arguments.imageSize.height { - c.fill(arguments.drawingRect) - } + if let fullSizeImage = fullSizeImage { + let context = DrawingContext(size: arguments.drawingSize, clear: true) - if let fullSizeImage = fullSizeImage { + let fittedSize = CGSize(width: fullSizeImage.width, height: fullSizeImage.height).aspectFilled(arguments.boundingSize) + let drawingRect = arguments.drawingRect + + 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) + + context.withFlippedContext { c in + c.setBlendMode(.copy) + if arguments.boundingSize.width > arguments.imageSize.width || arguments.boundingSize.height > arguments.imageSize.height { + c.fill(arguments.drawingRect) + } + c.interpolationQuality = .medium c.draw(fullSizeImage, in: fittedRect) } + + addCorners(context, arguments: arguments) + + return context + } else { + return nil } - - addCorners(context, arguments: arguments) - - return context } } } -func chatMessageVideo(account: Account, video: TelegramMediaFile) -> Signal<(TransformImageArguments) -> DrawingContext, NoError> { +func chatMessageVideo(account: Account, video: TelegramMediaFile) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { let signal = chatMessageFileDatas(account: account, file: video) return signal |> map { (thumbnailData, fullSizeDataAndPath, fullSizeComplete) in @@ -731,7 +731,7 @@ func chatMessageVideo(account: Account, video: TelegramMediaFile) -> Signal<(Tra } } -func chatMessageImageFile(account: Account, file: TelegramMediaFile, progressive: Bool = false) -> Signal<(TransformImageArguments) -> DrawingContext, NoError> { +func chatMessageImageFile(account: Account, file: TelegramMediaFile, progressive: Bool = false) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { let signal = chatMessageFileDatas(account: account, file: file, progressive: progressive) return signal |> map { (thumbnailData, fullSizeDataAndPath, fullSizeComplete) in diff --git a/TelegramUI/StickerResources.swift b/TelegramUI/StickerResources.swift index 7d888b27d8..5ce42a213b 100644 --- a/TelegramUI/StickerResources.swift +++ b/TelegramUI/StickerResources.swift @@ -39,7 +39,7 @@ private func chatMessageStickerDatas(account: Account, file: TelegramMediaFile, let maybeFetched = account.postbox.mediaBox.cachedResourceRepresentation(file.resource, representation: CachedStickerAJpegRepresentation(size: small ? CGSize(width: 160.0, height: 160.0) : nil)) return maybeFetched |> take(1) |> mapToSignal { maybeData in - if maybeData.size >= file.size { + if maybeData.complete { let loadedData: Data? = try? Data(contentsOf: URL(fileURLWithPath: maybeData.path), options: []) return .single((nil, loadedData, true)) @@ -57,7 +57,7 @@ private func chatMessageStickerDatas(account: Account, file: TelegramMediaFile, } } -func chatMessageSticker(account: Account, file: TelegramMediaFile, small: Bool) -> Signal<(TransformImageArguments) -> DrawingContext, NoError> { +func chatMessageSticker(account: Account, file: TelegramMediaFile, small: Bool) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { let signal = chatMessageStickerDatas(account: account, file: file, small: small) return signal |> map { (thumbnailData, fullSizeData, fullSizeComplete) in diff --git a/TelegramUI/TransformImageNode.swift b/TelegramUI/TransformImageNode.swift index b62793c8a0..a4ce07955e 100644 --- a/TelegramUI/TransformImageNode.swift +++ b/TelegramUI/TransformImageNode.swift @@ -42,12 +42,16 @@ public class TransformImageNode: ASDisplayNode { self.disposable.dispose() } - func setSignal(account: Account, signal: Signal<(TransformImageArguments) -> DrawingContext, NoError>, dispatchOnDisplayLink: Bool = true) { + func setSignal(account: Account, signal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>, dispatchOnDisplayLink: Bool = true) { let argumentsPromise = self.argumentsPromise let result = combineLatest(signal, argumentsPromise.get()) |> deliverOn(Queue.concurrentDefaultQueue() /*account.graphicsThreadPool*/) |> mapToThrottled { transform, arguments -> Signal in return deferred { - return Signal.single(transform(arguments).generateImage()) + if let context = transform(arguments) { + return Signal.single(context.generateImage()) + } else { + return Signal.single(nil) + } } } diff --git a/TelegramUI/UrlHandling.swift b/TelegramUI/UrlHandling.swift new file mode 100644 index 0000000000..e3485d5580 --- /dev/null +++ b/TelegramUI/UrlHandling.swift @@ -0,0 +1,48 @@ +import Foundation +import Postbox +import SafariServices +import Display + +enum PeerUrlParameter { + case botStart(String) + case groupBotStart(String) +} + +enum ParsedUrl { + case external(String) + case peerId(PeerId) + case peerName(String, [PeerUrlParameter]) +} + +func parseUrl(_ url: String) -> ParsedUrl { + return .external(url) +} + +private final class SafariLegacyPresentedController: LegacyPresentedController, SFSafariViewControllerDelegate { + @available(iOSApplicationExtension 9.0, *) + init(legacyController: SFSafariViewController) { + super.init(legacyController: legacyController, presentation: .custom) + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @available(iOSApplicationExtension 9.0, *) + func safariViewControllerDidFinish(_ controller: SFSafariViewController) { + self.dismiss() + } +} + +func openUrl(_ url: String, in window: Window) { + if #available(iOSApplicationExtension 9.0, *) { + if let url = URL(string: url) { + let controller = SFSafariViewController(url: url) + //window.rootViewController?.present(controller, animated: true, completion: nil) + let legacyController = SafariLegacyPresentedController(legacyController: controller) + controller.delegate = legacyController + window.present(legacyController) + } + } else { + } +} diff --git a/TelegramUI/VerticalListContextResultsChatInputContextPanelNode.swift b/TelegramUI/VerticalListContextResultsChatInputContextPanelNode.swift new file mode 100644 index 0000000000..54bd371b82 --- /dev/null +++ b/TelegramUI/VerticalListContextResultsChatInputContextPanelNode.swift @@ -0,0 +1,205 @@ +import Foundation +import AsyncDisplayKit +import Postbox +import TelegramCore +import Display + +private struct ChatContextResultStableId: Hashable { + let result: ChatContextResult + + var hashValue: Int { + return result.id.hashValue + } + + static func ==(lhs: ChatContextResultStableId, rhs: ChatContextResultStableId) -> Bool { + return lhs.result == rhs.result + } +} + +private struct VerticalListContextResultsChatInputContextPanelEntry: Equatable, Comparable, Identifiable { + let index: Int + let result: ChatContextResult + + var stableId: ChatContextResultStableId { + return ChatContextResultStableId(result: self.result) + } + + static func ==(lhs: VerticalListContextResultsChatInputContextPanelEntry, rhs: VerticalListContextResultsChatInputContextPanelEntry) -> Bool { + return lhs.index == rhs.index && lhs.result == rhs.result + } + + static func <(lhs: VerticalListContextResultsChatInputContextPanelEntry, rhs: VerticalListContextResultsChatInputContextPanelEntry) -> Bool { + return lhs.index < rhs.index + } + + func item(account: Account, resultSelected: @escaping (ChatContextResult) -> Void) -> ListViewItem { + return VerticalListContextResultsChatInputPanelItem(account: account, result: self.result, resultSelected: resultSelected) + } +} + +private struct VerticalListContextResultsChatInputContextPanelTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] +} + +private func preparedTransition(from fromEntries: [VerticalListContextResultsChatInputContextPanelEntry], to toEntries: [VerticalListContextResultsChatInputContextPanelEntry], account: Account, resultSelected: @escaping (ChatContextResult) -> Void) -> VerticalListContextResultsChatInputContextPanelTransition { + 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: $0.1.item(account: account, resultSelected: resultSelected), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, resultSelected: resultSelected), directionHint: nil) } + + return VerticalListContextResultsChatInputContextPanelTransition(deletions: deletions, insertions: insertions, updates: updates) +} + +final class VerticalListContextResultsChatInputContextPanelNode: ChatInputContextPanelNode { + private let listView: ListView + private var currentResults: ChatContextResultCollection? + private var currentEntries: [VerticalListContextResultsChatInputContextPanelEntry]? + + private var enqueuedTransitions: [(VerticalListContextResultsChatInputContextPanelTransition, Bool)] = [] + private var hasValidLayout = false + + override init(account: Account) { + self.listView = ListView() + self.listView.isOpaque = false + self.listView.stackFromBottom = true + self.listView.stackFromBottomInsetItemFactor = 3.5 + self.listView.limitHitTestToNodes = true + + super.init(account: account) + + self.isOpaque = false + self.clipsToBounds = true + + self.addSubnode(self.listView) + } + + func updateResults(_ results: ChatContextResultCollection) { + self.currentResults = results + var entries: [VerticalListContextResultsChatInputContextPanelEntry] = [] + var index = 0 + var resultIds = Set() + for result in results.results { + let entry = VerticalListContextResultsChatInputContextPanelEntry(index: index, result: result) + if resultIds.contains(entry.stableId) { + continue + } else { + resultIds.insert(entry.stableId) + } + entries.append(entry) + index += 1 + } + + let firstTime = self.currentEntries == nil + let transition = preparedTransition(from: self.currentEntries ?? [], to: entries, account: self.account, resultSelected: { [weak self] result in + if let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction { + interfaceInteraction.sendContextResult(results, result) + } + }) + self.currentEntries = entries + self.enqueueTransition(transition, firstTime: firstTime) + } + + private func enqueueTransition(_ transition: VerticalListContextResultsChatInputContextPanelTransition, firstTime: Bool) { + enqueuedTransitions.append((transition, firstTime)) + + if self.hasValidLayout { + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransition() + } + } + } + + private func dequeueTransition() { + if let (transition, firstTime) = self.enqueuedTransitions.first { + self.enqueuedTransitions.remove(at: 0) + + var options = ListViewDeleteAndInsertOptions() + if firstTime { + options.insert(.Synchronous) + options.insert(.LowLatency) + } else { + //options.insert(.AnimateInsertion) + } + self.listView.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateOpaqueState: nil, completion: { [weak self] _ in + if let strongSelf = self, firstTime { + var topItemOffset: CGFloat? + strongSelf.listView.forEachItemNode { itemNode in + if topItemOffset == nil { + topItemOffset = itemNode.frame.minY + } + } + + if let topItemOffset = topItemOffset { + let position = strongSelf.listView.layer.position + strongSelf.listView.layer.animatePosition(from: CGPoint(x: position.x, y: position.y + (strongSelf.listView.bounds.size.height - topItemOffset)), to: position, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + } + } + }) + } + } + + override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { + var insets = UIEdgeInsets() + + transition.updateFrame(node: self.listView, frame: CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height)) + + 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 listViewCurve: ListViewAnimationCurve + if curve == 7 { + listViewCurve = .Spring(duration: duration) + } else { + listViewCurve = .Default + } + + let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: size, insets: insets, duration: duration, curve: listViewCurve) + + self.listView.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + + if !hasValidLayout { + hasValidLayout = true + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransition() + } + } + } + + override func animateOut(completion: @escaping () -> Void) { + var topItemOffset: CGFloat? + self.listView.forEachItemNode { itemNode in + if topItemOffset == nil { + topItemOffset = itemNode.frame.minY + } + } + + if let topItemOffset = topItemOffset { + let position = self.listView.layer.position + self.listView.layer.animatePosition(from: position, to: CGPoint(x: position.x, y: position.y + (self.listView.bounds.size.height - topItemOffset)), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in + completion() + }) + } else { + completion() + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let listViewFrame = self.listView.frame + return self.listView.hitTest(CGPoint(x: point.x - listViewFrame.minX, y: point.y - listViewFrame.minY), with: event) + } +} diff --git a/TelegramUI/VerticalListContextResultsChatInputPanelItem.swift b/TelegramUI/VerticalListContextResultsChatInputPanelItem.swift new file mode 100644 index 0000000000..ae1c3ae122 --- /dev/null +++ b/TelegramUI/VerticalListContextResultsChatInputPanelItem.swift @@ -0,0 +1,338 @@ +import Foundation +import AsyncDisplayKit +import Display +import TelegramCore +import SwiftSignalKit +import Postbox + +final class VerticalListContextResultsChatInputPanelItem: ListViewItem { + fileprivate let account: Account + fileprivate let result: ChatContextResult + private let resultSelected: (ChatContextResult) -> Void + + let selectable: Bool = true + + public init(account: Account, result: ChatContextResult, resultSelected: @escaping (ChatContextResult) -> Void) { + self.account = account + self.result = result + self.resultSelected = resultSelected + } + + public func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> Void) -> Void) { + let configure = { () -> Void in + let node = VerticalListContextResultsChatInputPanelItemNode() + + let nodeLayout = node.asyncLayout() + let (top, bottom) = (previousItem != nil, nextItem != nil) + let (layout, apply) = nodeLayout(self, width, top, bottom) + + 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? VerticalListContextResultsChatInputPanelItemNode { + Queue.mainQueue().async { + let nodeLayout = node.asyncLayout() + + async { + let (top, bottom) = (previousItem != nil, nextItem != nil) + + let (layout, apply) = nodeLayout(self, width, top, bottom) + Queue.mainQueue().async { + completion(layout, { + apply(animation) + }) + } + } + } + } else { + assertionFailure() + } + } + + func selected(listView: ListView) { + self.resultSelected(self.result) + } +} + +private let titleFont = Font.medium(16.0) +private let textFont = Font.regular(15.0) +private let iconFont = Font.medium(25.0) +private let iconTextBackgroundImage = generateStretchableFilledCircleImage(radius: 2.0, color: UIColor(0xdfdfdf)) + +final class VerticalListContextResultsChatInputPanelItemNode: ListViewItemNode { + static let itemHeight: CGFloat = 75.0 + + private let iconTextBackgroundNode: ASImageNode + private let iconTextNode: TextNode + private let iconImageNode: TransformImageNode + private let titleNode: TextNode + private let textNode: TextNode + private let topSeparatorNode: ASDisplayNode + private let separatorNode: ASDisplayNode + private let highlightedBackgroundNode: ASDisplayNode + + private var currentIconImageResource: TelegramMediaResource? + + init() { + self.titleNode = TextNode() + self.textNode = TextNode() + + self.topSeparatorNode = ASDisplayNode() + self.topSeparatorNode.backgroundColor = UIColor(0xC9CDD1) + self.topSeparatorNode.isLayerBacked = true + + self.separatorNode = ASDisplayNode() + self.separatorNode.backgroundColor = UIColor(0xD6D6DA) + self.separatorNode.isLayerBacked = true + + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.backgroundColor = UIColor(0xd9d9d9) + self.highlightedBackgroundNode.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(layerBacked: false, dynamicBounce: false) + + self.backgroundColor = .white + + self.addSubnode(self.topSeparatorNode) + self.addSubnode(self.separatorNode) + + self.addSubnode(self.iconImageNode) + self.addSubnode(self.titleNode) + self.addSubnode(self.textNode) + } + + override public func layoutForWidth(_ width: CGFloat, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { + if let item = item as? VerticalListContextResultsChatInputPanelItem { + let doLayout = self.asyncLayout() + let merged = (top: previousItem != nil, bottom: nextItem != nil) + let (layout, apply) = doLayout(item, width, merged.top, merged.bottom) + self.contentSize = layout.contentSize + self.insets = layout.insets + apply(.None) + } + } + + func asyncLayout() -> (_ item: VerticalListContextResultsChatInputPanelItem, _ width: CGFloat, _ mergedTop: Bool, _ mergedBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + let makeTextLayout = TextNode.asyncLayout(self.textNode) + let iconTextMakeLayout = TextNode.asyncLayout(self.iconTextNode) + let iconImageLayout = self.iconImageNode.asyncLayout() + let currentIconImageResource = self.currentIconImageResource + + return { [weak self] item, width, mergedTop, mergedBottom in + let leftInset: CGFloat = 80.0 + let rightInset: CGFloat = 10.0 + + let applyIconTextBackgroundImage = iconTextBackgroundImage + + var titleString: NSAttributedString? + var textString: NSAttributedString? + var iconText: NSAttributedString? + + var iconImageRepresentation: TelegramMediaImageRepresentation? + var updateIconImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? + + if let title = item.result.title { + titleString = NSAttributedString(string: title, font: titleFont, textColor: .black) + } + + if let text = item.result.description { + textString = NSAttributedString(string: text, font: textFont, textColor: UIColor(0x8e8e93)) + } + + var imageResource: TelegramMediaResource? + switch item.result { + case let .externalReference(_, _, title, _, url, thumbnailUrl, contentUrl, _, dimensions, _, _): + if let thumbnailUrl = thumbnailUrl { + imageResource = HttpReferenceMediaResource(url: thumbnailUrl, size: nil) + } + var selectedUrl: String? + if let url = url { + selectedUrl = url + } else if let contentUrl = contentUrl { + selectedUrl = contentUrl + } + if let selectedUrl = selectedUrl, let parsedUrl = URL(string: selectedUrl) { + if let host = parsedUrl.host, !host.isEmpty { + iconText = NSAttributedString(string: host.substring(to: host.index(after: host.startIndex)).uppercased(), font: iconFont, textColor: UIColor.white) + } + } + case let .internalReference(_, _, title, _, image, file, _): + if let image = image { + imageResource = smallestImageRepresentation(image.representations)?.resource + } else if let file = file { + imageResource = smallestImageRepresentation(file.previewRepresentations)?.resource + } + } + + if iconText == nil { + if let title = item.result.title, !title.isEmpty { + let titleText = title.substring(to: title.index(after: title.startIndex)).uppercased() + iconText = NSAttributedString(string: titleText, font: iconFont, textColor: UIColor.white) + } + } + + var iconImageApply: (() -> Void)? + if let imageResource = imageResource { + let iconSize = CGSize(width: 55.0, height: 55.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: iconSize, boundingSize: iconSize, intrinsicInsets: UIEdgeInsets()) + iconImageApply = iconImageLayout(arguments) + } + + var updatedIconImageResource = false + if let currentIconImageResource = currentIconImageResource, let imageResource = imageResource { + if !currentIconImageResource.isEqual(to: imageResource) { + updatedIconImageResource = true + } + } else if (currentIconImageResource != nil) != (imageResource != nil) { + updatedIconImageResource = true + } + + if updatedIconImageResource { + if let imageResource = imageResource { + let tmpRepresentation = TelegramMediaImageRepresentation(dimensions: CGSize(width: 55.0, height: 55.0), resource: imageResource) + let tmpImage = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: [tmpRepresentation]) + updateIconImageSignal = chatWebpageSnippetPhoto(account: item.account, photo: tmpImage) + } else { + updateIconImageSignal = .complete() + } + } + + let (titleLayout, titleApply) = makeTitleLayout(titleString, nil, 1, .end, CGSize(width: width - leftInset - rightInset, height: 100.0), nil) + + let (textLayout, textApply) = makeTextLayout(textString, nil, 2, .end, CGSize(width: width - leftInset - rightInset, height: 100.0), nil) + + let (iconTextLayout, iconTextApply) = iconTextMakeLayout(iconText, nil, 1, .end, CGSize(width: 38.0, height: CGFloat.infinity), nil) + + var titleFrame: CGRect? + if let _ = titleString { + titleFrame = CGRect(origin: CGPoint(x: leftInset, y: 9.0), size: titleLayout.size) + } + + var textFrame: CGRect? + if let _ = textString { + var topOffset: CGFloat = 9.0 + if let titleFrame = titleFrame { + topOffset = titleFrame.maxY + 1.0 + } + textFrame = CGRect(origin: CGPoint(x: leftInset, y: topOffset), size: textLayout.size) + } + + let nodeLayout = ListViewItemNodeLayout(contentSize: CGSize(width: width, height: VerticalListContextResultsChatInputPanelItemNode.itemHeight), insets: UIEdgeInsets()) + + return (nodeLayout, { _ in + if let strongSelf = self { + titleApply() + textApply() + + if let titleFrame = titleFrame { + strongSelf.titleNode.frame = titleFrame + } + if let textFrame = textFrame { + strongSelf.textNode.frame = textFrame + } + + let iconFrame = CGRect(origin: CGPoint(x: 12.0, y: 11.0), size: CGSize(width: 55.0, height: 55.0)) + strongSelf.iconTextNode.frame = CGRect(origin: CGPoint(x: iconFrame.minX + floor((55.0 - iconTextLayout.size.width) / 2.0), y: iconFrame.minY + floor((55.0 - iconTextLayout.size.height) / 2.0) + 2.0), size: iconTextLayout.size) + + let _ = iconTextApply() + + strongSelf.currentIconImageResource = imageResource + + 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) + } + } + + strongSelf.topSeparatorNode.isHidden = mergedTop + strongSelf.separatorNode.isHidden = !mergedBottom + + strongSelf.topSeparatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: UIScreenPixel)) + strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: nodeLayout.contentSize.height - UIScreenPixel), size: CGSize(width: width - leftInset, height: UIScreenPixel)) + + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: nodeLayout.size.height + 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() + } + } + } + } +}