diff --git a/Images.xcassets/Chat List/Tabs/IconChats.imageset/Contents.json b/Images.xcassets/Chat List/Tabs/IconChats.imageset/Contents.json index 9d36fc4473..67b8fd9f58 100644 --- a/Images.xcassets/Chat List/Tabs/IconChats.imageset/Contents.json +++ b/Images.xcassets/Chat List/Tabs/IconChats.imageset/Contents.json @@ -11,6 +11,7 @@ }, { "idiom" : "universal", + "filename" : "TabIconMessages@3x.png", "scale" : "3x" } ], diff --git a/Images.xcassets/Chat List/Tabs/IconChats.imageset/TabIconMessages@2x.png b/Images.xcassets/Chat List/Tabs/IconChats.imageset/TabIconMessages@2x.png index 30b8a0097e..ca04fededf 100644 Binary files a/Images.xcassets/Chat List/Tabs/IconChats.imageset/TabIconMessages@2x.png and b/Images.xcassets/Chat List/Tabs/IconChats.imageset/TabIconMessages@2x.png differ diff --git a/Images.xcassets/Chat List/Tabs/IconChats.imageset/TabIconMessages@3x.png b/Images.xcassets/Chat List/Tabs/IconChats.imageset/TabIconMessages@3x.png new file mode 100644 index 0000000000..887f6d649a Binary files /dev/null and b/Images.xcassets/Chat List/Tabs/IconChats.imageset/TabIconMessages@3x.png differ diff --git a/Images.xcassets/Chat List/Tabs/IconChatsSelected.imageset/Contents.json b/Images.xcassets/Chat List/Tabs/IconChatsSelected.imageset/Contents.json index 0cb58499a6..aab44bc924 100644 --- a/Images.xcassets/Chat List/Tabs/IconChatsSelected.imageset/Contents.json +++ b/Images.xcassets/Chat List/Tabs/IconChatsSelected.imageset/Contents.json @@ -11,6 +11,7 @@ }, { "idiom" : "universal", + "filename" : "TabIconMessages_Highlighted@3x.png", "scale" : "3x" } ], diff --git a/Images.xcassets/Chat List/Tabs/IconChatsSelected.imageset/TabIconMessages_Highlighted@2x.png b/Images.xcassets/Chat List/Tabs/IconChatsSelected.imageset/TabIconMessages_Highlighted@2x.png index 23612a4c9c..024445c9b0 100644 Binary files a/Images.xcassets/Chat List/Tabs/IconChatsSelected.imageset/TabIconMessages_Highlighted@2x.png and b/Images.xcassets/Chat List/Tabs/IconChatsSelected.imageset/TabIconMessages_Highlighted@2x.png differ diff --git a/Images.xcassets/Chat List/Tabs/IconChatsSelected.imageset/TabIconMessages_Highlighted@3x.png b/Images.xcassets/Chat List/Tabs/IconChatsSelected.imageset/TabIconMessages_Highlighted@3x.png new file mode 100644 index 0000000000..c935785bb9 Binary files /dev/null and b/Images.xcassets/Chat List/Tabs/IconChatsSelected.imageset/TabIconMessages_Highlighted@3x.png differ diff --git a/Images.xcassets/Chat List/Tabs/IconContacts.imageset/Contents.json b/Images.xcassets/Chat List/Tabs/IconContacts.imageset/Contents.json index 2a12bb8512..d9cd705b50 100644 --- a/Images.xcassets/Chat List/Tabs/IconContacts.imageset/Contents.json +++ b/Images.xcassets/Chat List/Tabs/IconContacts.imageset/Contents.json @@ -11,6 +11,7 @@ }, { "idiom" : "universal", + "filename" : "TabIconContacts@3x.png", "scale" : "3x" } ], diff --git a/Images.xcassets/Chat List/Tabs/IconContacts.imageset/TabIconContacts@2x.png b/Images.xcassets/Chat List/Tabs/IconContacts.imageset/TabIconContacts@2x.png index 4a523ac902..ff709a9dc6 100644 Binary files a/Images.xcassets/Chat List/Tabs/IconContacts.imageset/TabIconContacts@2x.png and b/Images.xcassets/Chat List/Tabs/IconContacts.imageset/TabIconContacts@2x.png differ diff --git a/Images.xcassets/Chat List/Tabs/IconContacts.imageset/TabIconContacts@3x.png b/Images.xcassets/Chat List/Tabs/IconContacts.imageset/TabIconContacts@3x.png new file mode 100644 index 0000000000..0bd8bc85d8 Binary files /dev/null and b/Images.xcassets/Chat List/Tabs/IconContacts.imageset/TabIconContacts@3x.png differ diff --git a/Images.xcassets/Chat List/Tabs/IconContactsSelected.imageset/Contents.json b/Images.xcassets/Chat List/Tabs/IconContactsSelected.imageset/Contents.json index e9b76ca372..38dfa084e4 100644 --- a/Images.xcassets/Chat List/Tabs/IconContactsSelected.imageset/Contents.json +++ b/Images.xcassets/Chat List/Tabs/IconContactsSelected.imageset/Contents.json @@ -11,6 +11,7 @@ }, { "idiom" : "universal", + "filename" : "TabIconContacts_Highlighted@3x.png", "scale" : "3x" } ], diff --git a/Images.xcassets/Chat List/Tabs/IconContactsSelected.imageset/TabIconContacts_Highlighted@2x.png b/Images.xcassets/Chat List/Tabs/IconContactsSelected.imageset/TabIconContacts_Highlighted@2x.png index 20d555e6d3..b7e573a3f5 100644 Binary files a/Images.xcassets/Chat List/Tabs/IconContactsSelected.imageset/TabIconContacts_Highlighted@2x.png and b/Images.xcassets/Chat List/Tabs/IconContactsSelected.imageset/TabIconContacts_Highlighted@2x.png differ diff --git a/Images.xcassets/Chat List/Tabs/IconContactsSelected.imageset/TabIconContacts_Highlighted@3x.png b/Images.xcassets/Chat List/Tabs/IconContactsSelected.imageset/TabIconContacts_Highlighted@3x.png new file mode 100644 index 0000000000..439091bc50 Binary files /dev/null and b/Images.xcassets/Chat List/Tabs/IconContactsSelected.imageset/TabIconContacts_Highlighted@3x.png differ diff --git a/Images.xcassets/Chat List/Tabs/IconSettings.imageset/Contents.json b/Images.xcassets/Chat List/Tabs/IconSettings.imageset/Contents.json index d12dc44869..1102b437dc 100644 --- a/Images.xcassets/Chat List/Tabs/IconSettings.imageset/Contents.json +++ b/Images.xcassets/Chat List/Tabs/IconSettings.imageset/Contents.json @@ -11,6 +11,7 @@ }, { "idiom" : "universal", + "filename" : "TabIconSettings@3x.png", "scale" : "3x" } ], diff --git a/Images.xcassets/Chat List/Tabs/IconSettings.imageset/TabIconSettings@2x.png b/Images.xcassets/Chat List/Tabs/IconSettings.imageset/TabIconSettings@2x.png index c1a9926fc4..61e3ad0d9b 100644 Binary files a/Images.xcassets/Chat List/Tabs/IconSettings.imageset/TabIconSettings@2x.png and b/Images.xcassets/Chat List/Tabs/IconSettings.imageset/TabIconSettings@2x.png differ diff --git a/Images.xcassets/Chat List/Tabs/IconSettings.imageset/TabIconSettings@3x.png b/Images.xcassets/Chat List/Tabs/IconSettings.imageset/TabIconSettings@3x.png new file mode 100644 index 0000000000..4a6d24a1ff Binary files /dev/null and b/Images.xcassets/Chat List/Tabs/IconSettings.imageset/TabIconSettings@3x.png differ diff --git a/Images.xcassets/Chat List/Tabs/IconSettingsSelected.imageset/Contents.json b/Images.xcassets/Chat List/Tabs/IconSettingsSelected.imageset/Contents.json index 51e3998e8b..8036cc64b9 100644 --- a/Images.xcassets/Chat List/Tabs/IconSettingsSelected.imageset/Contents.json +++ b/Images.xcassets/Chat List/Tabs/IconSettingsSelected.imageset/Contents.json @@ -11,6 +11,7 @@ }, { "idiom" : "universal", + "filename" : "TabIconSettings_Highlighted@3x.png", "scale" : "3x" } ], diff --git a/Images.xcassets/Chat List/Tabs/IconSettingsSelected.imageset/TabIconSettings_Highlighted@2x.png b/Images.xcassets/Chat List/Tabs/IconSettingsSelected.imageset/TabIconSettings_Highlighted@2x.png index 33dd36bb4b..a734866a7a 100644 Binary files a/Images.xcassets/Chat List/Tabs/IconSettingsSelected.imageset/TabIconSettings_Highlighted@2x.png and b/Images.xcassets/Chat List/Tabs/IconSettingsSelected.imageset/TabIconSettings_Highlighted@2x.png differ diff --git a/Images.xcassets/Chat List/Tabs/IconSettingsSelected.imageset/TabIconSettings_Highlighted@3x.png b/Images.xcassets/Chat List/Tabs/IconSettingsSelected.imageset/TabIconSettings_Highlighted@3x.png new file mode 100644 index 0000000000..06bb4b120d Binary files /dev/null and b/Images.xcassets/Chat List/Tabs/IconSettingsSelected.imageset/TabIconSettings_Highlighted@3x.png differ diff --git a/Images.xcassets/Settings/ChangePhoneIntroIcon.imageset/ChangePhoneHelpIcon@2x.png b/Images.xcassets/Settings/ChangePhoneIntroIcon.imageset/ChangePhoneHelpIcon@2x.png new file mode 100644 index 0000000000..9af43ef97c Binary files /dev/null and b/Images.xcassets/Settings/ChangePhoneIntroIcon.imageset/ChangePhoneHelpIcon@2x.png differ diff --git a/Images.xcassets/Settings/ChangePhoneIntroIcon.imageset/ChangePhoneHelpIcon@3x.png b/Images.xcassets/Settings/ChangePhoneIntroIcon.imageset/ChangePhoneHelpIcon@3x.png new file mode 100644 index 0000000000..d070dd777a Binary files /dev/null and b/Images.xcassets/Settings/ChangePhoneIntroIcon.imageset/ChangePhoneHelpIcon@3x.png differ diff --git a/Images.xcassets/Settings/ChangePhoneIntroIcon.imageset/Contents.json b/Images.xcassets/Settings/ChangePhoneIntroIcon.imageset/Contents.json new file mode 100644 index 0000000000..1229ebd474 --- /dev/null +++ b/Images.xcassets/Settings/ChangePhoneIntroIcon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ChangePhoneHelpIcon@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "ChangePhoneHelpIcon@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Settings/Contents.json b/Images.xcassets/Settings/Contents.json new file mode 100644 index 0000000000..38f0c81fc2 --- /dev/null +++ b/Images.xcassets/Settings/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "provides-namespace" : true + } +} \ No newline at end of file diff --git a/TelegramUI.xcodeproj/project.pbxproj b/TelegramUI.xcodeproj/project.pbxproj index bd428c2a2b..fd206a9252 100644 --- a/TelegramUI.xcodeproj/project.pbxproj +++ b/TelegramUI.xcodeproj/project.pbxproj @@ -47,6 +47,9 @@ D01B279D1E394A500022A4C0 /* NotificationsAndSounds.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01B279C1E394A500022A4C0 /* NotificationsAndSounds.swift */; }; D01B279F1E394BD70022A4C0 /* InAppNotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01B279E1E394BD70022A4C0 /* InAppNotificationSettings.swift */; }; D01B27A41E394FC90022A4C0 /* SecuritySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01B27A31E394FC90022A4C0 /* SecuritySettings.swift */; }; + D01C2AA11E758F90001F6F9A /* NavigateToChatController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01C2AA01E758F90001F6F9A /* NavigateToChatController.swift */; }; + D01C2AAB1E75E010001F6F9A /* TwoStepVerificationUnlockController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01C2AAA1E75E010001F6F9A /* TwoStepVerificationUnlockController.swift */; }; + D01C2AAD1E768404001F6F9A /* Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01C2AAC1E768404001F6F9A /* Markdown.swift */; }; D01D6BFC1E42AB3C006151C6 /* EmojiUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01D6BFB1E42AB3C006151C6 /* EmojiUtils.swift */; }; D01F66131DE8903300345CBE /* ChatTextInputAudioRecordingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F66121DE8903300345CBE /* ChatTextInputAudioRecordingButton.swift */; }; D0215D381E040F53001A0B1E /* InstantPageNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0215D371E040F53001A0B1E /* InstantPageNode.swift */; }; @@ -101,6 +104,7 @@ D03E5E091E55C49C0029569A /* DebugAccountsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03E5E081E55C49C0029569A /* DebugAccountsController.swift */; }; D03E5E0F1E55F8B90029569A /* ChannelVisibilityController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03E5E0E1E55F8B90029569A /* ChannelVisibilityController.swift */; }; D04662811E68BA64006FAFC4 /* TransformOutgoingMessageMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04662801E68BA64006FAFC4 /* TransformOutgoingMessageMedia.swift */; }; + D04791671E79A22000F18979 /* ItemListStickerPackItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04791661E79A22000F18979 /* ItemListStickerPackItem.swift */; }; D0486F0A1E523C8500091F0C /* GroupInfoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0486F091E523C8500091F0C /* GroupInfoController.swift */; }; D049EAE21E447AD500A2CD3A /* HorizontalStickersChatContextPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D049EAE11E447AD500A2CD3A /* HorizontalStickersChatContextPanelNode.swift */; }; D049EAE41E44949F00A2CD3A /* HorizontalStickerGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D049EAE31E44949F00A2CD3A /* HorizontalStickerGridItem.swift */; }; @@ -197,6 +201,8 @@ D05A32EA1E6F143C002760B4 /* RecentSessionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05A32E91E6F143C002760B4 /* RecentSessionsController.swift */; }; D05A32EC1E6F1462002760B4 /* BlockedPeersController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05A32EB1E6F1462002760B4 /* BlockedPeersController.swift */; }; D05A32EE1E6F25A0002760B4 /* ItemListRecentSessionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05A32ED1E6F25A0002760B4 /* ItemListRecentSessionItem.swift */; }; + D05B724D1E720393000BD3AD /* SelectivePrivacySettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05B724C1E720393000BD3AD /* SelectivePrivacySettingsController.swift */; }; + D05B72501E720597000BD3AD /* PresentationData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05B724F1E720597000BD3AD /* PresentationData.swift */; }; D0613FC81E5F8AB100202CDB /* ChannelInfoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0613FC71E5F8AB100202CDB /* ChannelInfoController.swift */; }; D0613FCD1E60482300202CDB /* ChannelMembersController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0613FCC1E60482300202CDB /* ChannelMembersController.swift */; }; D0613FD51E6064D200202CDB /* ConvertToSupergroupController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0613FD41E6064D200202CDB /* ConvertToSupergroupController.swift */; }; @@ -254,6 +260,9 @@ D099EA2D1DE76782001AF5A8 /* PeerMessageManagedMediaId.swift in Sources */ = {isa = PBXBuildFile; fileRef = D099EA2C1DE76782001AF5A8 /* PeerMessageManagedMediaId.swift */; }; D099EA2F1DE775BB001AF5A8 /* ChatContextResultManagedMediaId.swift in Sources */ = {isa = PBXBuildFile; fileRef = D099EA2E1DE775BB001AF5A8 /* ChatContextResultManagedMediaId.swift */; }; D09AEFD41E5BAF67005C1A8B /* ItemListTextEmptyStateItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09AEFD31E5BAF67005C1A8B /* ItemListTextEmptyStateItem.swift */; }; + D0A11BFA1E7836C20081CE03 /* ChangePhoneNumberIntroController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A11BF91E7836C20081CE03 /* ChangePhoneNumberIntroController.swift */; }; + D0A11BFC1E7840750081CE03 /* ChangePhoneNumberController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A11BFB1E7840750081CE03 /* ChangePhoneNumberController.swift */; }; + D0A11BFE1E7840A50081CE03 /* ChangePhoneNumberControllerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A11BFD1E7840A50081CE03 /* ChangePhoneNumberControllerNode.swift */; }; D0A749971E3AA25200AD786E /* NotificationSoundSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A749961E3AA25200AD786E /* NotificationSoundSelection.swift */; }; D0AB0BB11D6718DA002C78E7 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D0AB0BB01D6718DA002C78E7 /* libiconv.tbd */; }; D0AB0BB31D6718EB002C78E7 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D0AB0BB21D6718EB002C78E7 /* libz.tbd */; }; @@ -265,7 +274,6 @@ D0B843921DA7F13E005F29E1 /* ItemListDisclosureItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843911DA7F13E005F29E1 /* ItemListDisclosureItem.swift */; }; D0B843CD1DA903BB005F29E1 /* PeerInfoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843CC1DA903BB005F29E1 /* PeerInfoController.swift */; }; D0B843CF1DA922AD005F29E1 /* PeerInfoEntries.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843CE1DA922AD005F29E1 /* PeerInfoEntries.swift */; }; - D0B843D11DA922D7005F29E1 /* UserInfoEntries.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843D01DA922D7005F29E1 /* UserInfoEntries.swift */; }; D0B843D91DAAAA0C005F29E1 /* ItemListPeerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843D81DAAAA0C005F29E1 /* ItemListPeerItem.swift */; }; D0B843DB1DAAB138005F29E1 /* ItemListPeerActionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B843DA1DAAB138005F29E1 /* ItemListPeerActionItem.swift */; }; D0B844561DAC3AEE005F29E1 /* PresenceStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B844551DAC3AEE005F29E1 /* PresenceStrings.swift */; }; @@ -278,6 +286,7 @@ D0BC386A1E3FB94D0044D6FE /* CreateGroupController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BC38691E3FB94D0044D6FE /* CreateGroupController.swift */; }; D0BC387F1E40F1CF0044D6FE /* ContactSelectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BC387E1E40F1CF0044D6FE /* ContactSelectionController.swift */; }; D0BC38811E40F1D80044D6FE /* ContactSelectionControllerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BC38801E40F1D80044D6FE /* ContactSelectionControllerNode.swift */; }; + D0BE383C1E7C3E51000079AF /* StickerPackGalleryController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BE383B1E7C3E51000079AF /* StickerPackGalleryController.swift */; }; D0C932361E0988C60074F044 /* ChatButtonKeyboardInputNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C932351E0988C60074F044 /* ChatButtonKeyboardInputNode.swift */; }; D0C932381E09E0EA0074F044 /* ChatBotInfoItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C932371E09E0EA0074F044 /* ChatBotInfoItem.swift */; }; D0C9323C1E0B4AE90074F044 /* DataAndStorageSettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C9323B1E0B4AE90074F044 /* DataAndStorageSettingsController.swift */; }; @@ -318,6 +327,9 @@ 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 */; }; + D0D748061E7AF63800F4B1F6 /* StickerPackPreviewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D748051E7AF63800F4B1F6 /* StickerPackPreviewController.swift */; }; + D0D748081E7AF64400F4B1F6 /* StickerPackPreviewControllerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D748071E7AF64400F4B1F6 /* StickerPackPreviewControllerNode.swift */; }; + D0D7480F1E7B1BD600F4B1F6 /* StickerPackPreviewGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D7480E1E7B1BD600F4B1F6 /* StickerPackPreviewGridItem.swift */; }; D0DA44541E4E7302005FDCA7 /* ProgressNavigationButtonNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DA44531E4E7302005FDCA7 /* ProgressNavigationButtonNode.swift */; }; D0DA44561E4E7F43005FDCA7 /* ShakeAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DA44551E4E7F43005FDCA7 /* ShakeAnimation.swift */; }; D0DC35441DE32230000195EB /* ChatInterfaceStateContextQueries.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DC35431DE32230000195EB /* ChatInterfaceStateContextQueries.swift */; }; @@ -341,6 +353,8 @@ D0DF0C9E1D82141F008AEB01 /* ChatInterfaceInputContexts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DF0C9D1D82141F008AEB01 /* ChatInterfaceInputContexts.swift */; }; D0DF0CA11D821B28008AEB01 /* HashtagChatInputPanelItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DF0CA01D821B28008AEB01 /* HashtagChatInputPanelItem.swift */; }; D0DF0CA41D82BCD0008AEB01 /* MentionChatInputContextPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DF0CA31D82BCD0008AEB01 /* MentionChatInputContextPanelNode.swift */; }; + D0E23DD81E805E2600B9B6D2 /* FeaturedStickerPacksController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E23DD71E805E2600B9B6D2 /* FeaturedStickerPacksController.swift */; }; + D0E23DDD1E8081A200B9B6D2 /* ArhivedStickerPacksController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E23DDC1E8081A200B9B6D2 /* ArhivedStickerPacksController.swift */; }; D0E305A51E5B2BFB00D7A3A2 /* ValidateAddressNameInteractive.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E305A41E5B2BFB00D7A3A2 /* ValidateAddressNameInteractive.swift */; }; D0E305AD1E5BA3E700D7A3A2 /* ItemListControllerEmptyStateItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E305AC1E5BA3E700D7A3A2 /* ItemListControllerEmptyStateItem.swift */; }; D0E305AF1E5BA8E000D7A3A2 /* ItemListLoadingIndicatorEmptyStateItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E305AE1E5BA8E000D7A3A2 /* ItemListLoadingIndicatorEmptyStateItem.swift */; }; @@ -352,7 +366,12 @@ 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 */; }; + D0EF40DD1E72F00E000DFCD4 /* SelectivePrivacySettingsPeersController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EF40DC1E72F00E000DFCD4 /* SelectivePrivacySettingsPeersController.swift */; }; + D0EF40DF1E73100D000DFCD4 /* ChatHistoryNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EF40DE1E73100D000DFCD4 /* ChatHistoryNavigationStack.swift */; }; D0EFD8961DDE8249009E508A /* LegacyLocationPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EFD8951DDE8249009E508A /* LegacyLocationPicker.swift */; }; + D0F53BEC1E784DA900117362 /* ChangePhoneNumberCodeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F53BEB1E784DA900117362 /* ChangePhoneNumberCodeController.swift */; }; + D0F53BF71E79593500117362 /* AuthorizationSequenceSignUpController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F53BF61E79593500117362 /* AuthorizationSequenceSignUpController.swift */; }; + D0F53BF91E79593F00117362 /* AuthorizationSequenceSignUpControllerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F53BF81E79593F00117362 /* AuthorizationSequenceSignUpControllerNode.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 */; }; @@ -474,6 +493,9 @@ D0F7AB351DCFADCD009AD9A1 /* ChatMessageBubbleImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F7AB341DCFADCD009AD9A1 /* ChatMessageBubbleImages.swift */; }; D0F7AB391DCFF87B009AD9A1 /* ChatMessageDateHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F7AB381DCFF87B009AD9A1 /* ChatMessageDateHeader.swift */; }; D0F917B51E0DA396003687E6 /* GenerateTextEntities.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F917B41E0DA396003687E6 /* GenerateTextEntities.swift */; }; + D0FA0ABF1E76E17F005BB9B7 /* TwoStepVerificationPasswordEntryController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA0ABE1E76E17F005BB9B7 /* TwoStepVerificationPasswordEntryController.swift */; }; + D0FA0AC11E7725AA005BB9B7 /* TwoStepVerificationResetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA0AC01E7725AA005BB9B7 /* TwoStepVerificationResetController.swift */; }; + D0FA0AC51E77431A005BB9B7 /* InstalledStickerPacksController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA0AC41E77431A005BB9B7 /* InstalledStickerPacksController.swift */; }; D0FC40891D5B8E7500261D9D /* TelegramUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0FC407F1D5B8E7400261D9D /* TelegramUI.framework */; }; D0FC408E1D5B8E7500261D9D /* TelegramUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FC408D1D5B8E7500261D9D /* TelegramUITests.swift */; }; D0FC40901D5B8E7500261D9D /* TelegramUI.h in Headers */ = {isa = PBXBuildFile; fileRef = D0FC40821D5B8E7400261D9D /* TelegramUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -530,6 +552,9 @@ D01B279C1E394A500022A4C0 /* NotificationsAndSounds.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationsAndSounds.swift; sourceTree = ""; }; D01B279E1E394BD70022A4C0 /* InAppNotificationSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppNotificationSettings.swift; sourceTree = ""; }; D01B27A31E394FC90022A4C0 /* SecuritySettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecuritySettings.swift; sourceTree = ""; }; + D01C2AA01E758F90001F6F9A /* NavigateToChatController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigateToChatController.swift; sourceTree = ""; }; + D01C2AAA1E75E010001F6F9A /* TwoStepVerificationUnlockController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TwoStepVerificationUnlockController.swift; sourceTree = ""; }; + D01C2AAC1E768404001F6F9A /* Markdown.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Markdown.swift; sourceTree = ""; }; D01D6BFB1E42AB3C006151C6 /* EmojiUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmojiUtils.swift; sourceTree = ""; }; D01F66121DE8903300345CBE /* ChatTextInputAudioRecordingButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatTextInputAudioRecordingButton.swift; sourceTree = ""; }; D0215D371E040F53001A0B1E /* InstantPageNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InstantPageNode.swift; sourceTree = ""; }; @@ -584,6 +609,7 @@ D03E5E081E55C49C0029569A /* DebugAccountsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugAccountsController.swift; sourceTree = ""; }; D03E5E0E1E55F8B90029569A /* ChannelVisibilityController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelVisibilityController.swift; sourceTree = ""; }; D04662801E68BA64006FAFC4 /* TransformOutgoingMessageMedia.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransformOutgoingMessageMedia.swift; sourceTree = ""; }; + D04791661E79A22000F18979 /* ItemListStickerPackItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListStickerPackItem.swift; sourceTree = ""; }; D0486F091E523C8500091F0C /* GroupInfoController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupInfoController.swift; sourceTree = ""; }; D049EAE11E447AD500A2CD3A /* HorizontalStickersChatContextPanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HorizontalStickersChatContextPanelNode.swift; sourceTree = ""; }; D049EAE31E44949F00A2CD3A /* HorizontalStickerGridItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HorizontalStickerGridItem.swift; sourceTree = ""; }; @@ -680,6 +706,8 @@ D05A32E91E6F143C002760B4 /* RecentSessionsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecentSessionsController.swift; sourceTree = ""; }; D05A32EB1E6F1462002760B4 /* BlockedPeersController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlockedPeersController.swift; sourceTree = ""; }; D05A32ED1E6F25A0002760B4 /* ItemListRecentSessionItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListRecentSessionItem.swift; sourceTree = ""; }; + D05B724C1E720393000BD3AD /* SelectivePrivacySettingsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectivePrivacySettingsController.swift; sourceTree = ""; }; + D05B724F1E720597000BD3AD /* PresentationData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PresentationData.swift; sourceTree = ""; }; D0613FC71E5F8AB100202CDB /* ChannelInfoController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelInfoController.swift; sourceTree = ""; }; D0613FCC1E60482300202CDB /* ChannelMembersController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelMembersController.swift; sourceTree = ""; }; D0613FD41E6064D200202CDB /* ConvertToSupergroupController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConvertToSupergroupController.swift; sourceTree = ""; }; @@ -738,6 +766,9 @@ D099EA2C1DE76782001AF5A8 /* PeerMessageManagedMediaId.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerMessageManagedMediaId.swift; sourceTree = ""; }; D099EA2E1DE775BB001AF5A8 /* ChatContextResultManagedMediaId.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatContextResultManagedMediaId.swift; sourceTree = ""; }; D09AEFD31E5BAF67005C1A8B /* ItemListTextEmptyStateItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListTextEmptyStateItem.swift; sourceTree = ""; }; + D0A11BF91E7836C20081CE03 /* ChangePhoneNumberIntroController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChangePhoneNumberIntroController.swift; sourceTree = ""; }; + D0A11BFB1E7840750081CE03 /* ChangePhoneNumberController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChangePhoneNumberController.swift; sourceTree = ""; }; + D0A11BFD1E7840A50081CE03 /* ChangePhoneNumberControllerNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChangePhoneNumberControllerNode.swift; sourceTree = ""; }; D0A749961E3AA25200AD786E /* NotificationSoundSelection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationSoundSelection.swift; sourceTree = ""; }; D0AB0BB01D6718DA002C78E7 /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = usr/lib/libiconv.tbd; sourceTree = SDKROOT; }; D0AB0BB21D6718EB002C78E7 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; @@ -751,7 +782,6 @@ D0B843911DA7F13E005F29E1 /* ItemListDisclosureItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListDisclosureItem.swift; sourceTree = ""; }; D0B843CC1DA903BB005F29E1 /* PeerInfoController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerInfoController.swift; sourceTree = ""; }; D0B843CE1DA922AD005F29E1 /* PeerInfoEntries.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerInfoEntries.swift; sourceTree = ""; }; - D0B843D01DA922D7005F29E1 /* UserInfoEntries.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserInfoEntries.swift; sourceTree = ""; }; D0B843D81DAAAA0C005F29E1 /* ItemListPeerItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListPeerItem.swift; sourceTree = ""; }; D0B843DA1DAAB138005F29E1 /* ItemListPeerActionItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListPeerActionItem.swift; sourceTree = ""; }; D0B844551DAC3AEE005F29E1 /* PresenceStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PresenceStrings.swift; sourceTree = ""; }; @@ -764,6 +794,7 @@ D0BC38691E3FB94D0044D6FE /* CreateGroupController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateGroupController.swift; sourceTree = ""; }; D0BC387E1E40F1CF0044D6FE /* ContactSelectionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactSelectionController.swift; sourceTree = ""; }; D0BC38801E40F1D80044D6FE /* ContactSelectionControllerNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactSelectionControllerNode.swift; sourceTree = ""; }; + D0BE383B1E7C3E51000079AF /* StickerPackGalleryController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StickerPackGalleryController.swift; sourceTree = ""; }; D0C932351E0988C60074F044 /* ChatButtonKeyboardInputNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatButtonKeyboardInputNode.swift; sourceTree = ""; }; D0C932371E09E0EA0074F044 /* ChatBotInfoItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatBotInfoItem.swift; sourceTree = ""; }; D0C9323B1E0B4AE90074F044 /* DataAndStorageSettingsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataAndStorageSettingsController.swift; sourceTree = ""; }; @@ -804,6 +835,9 @@ 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 = ""; }; + D0D748051E7AF63800F4B1F6 /* StickerPackPreviewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StickerPackPreviewController.swift; sourceTree = ""; }; + D0D748071E7AF64400F4B1F6 /* StickerPackPreviewControllerNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StickerPackPreviewControllerNode.swift; sourceTree = ""; }; + D0D7480E1E7B1BD600F4B1F6 /* StickerPackPreviewGridItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StickerPackPreviewGridItem.swift; sourceTree = ""; }; D0DA44531E4E7302005FDCA7 /* ProgressNavigationButtonNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgressNavigationButtonNode.swift; sourceTree = ""; }; D0DA44551E4E7F43005FDCA7 /* ShakeAnimation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShakeAnimation.swift; sourceTree = ""; }; D0DC35431DE32230000195EB /* ChatInterfaceStateContextQueries.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInterfaceStateContextQueries.swift; sourceTree = ""; }; @@ -827,6 +861,8 @@ D0DF0C9D1D82141F008AEB01 /* ChatInterfaceInputContexts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInterfaceInputContexts.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 = ""; }; + D0E23DD71E805E2600B9B6D2 /* FeaturedStickerPacksController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeaturedStickerPacksController.swift; sourceTree = ""; }; + D0E23DDC1E8081A200B9B6D2 /* ArhivedStickerPacksController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArhivedStickerPacksController.swift; sourceTree = ""; }; D0E305A41E5B2BFB00D7A3A2 /* ValidateAddressNameInteractive.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ValidateAddressNameInteractive.swift; sourceTree = ""; }; D0E305AC1E5BA3E700D7A3A2 /* ItemListControllerEmptyStateItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListControllerEmptyStateItem.swift; sourceTree = ""; }; D0E305AE1E5BA8E000D7A3A2 /* ItemListLoadingIndicatorEmptyStateItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListLoadingIndicatorEmptyStateItem.swift; sourceTree = ""; }; @@ -838,7 +874,12 @@ 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 = ""; }; + D0EF40DC1E72F00E000DFCD4 /* SelectivePrivacySettingsPeersController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectivePrivacySettingsPeersController.swift; sourceTree = ""; }; + D0EF40DE1E73100D000DFCD4 /* ChatHistoryNavigationStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatHistoryNavigationStack.swift; sourceTree = ""; }; D0EFD8951DDE8249009E508A /* LegacyLocationPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyLocationPicker.swift; sourceTree = ""; }; + D0F53BEB1E784DA900117362 /* ChangePhoneNumberCodeController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChangePhoneNumberCodeController.swift; sourceTree = ""; }; + D0F53BF61E79593500117362 /* AuthorizationSequenceSignUpController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthorizationSequenceSignUpController.swift; sourceTree = ""; }; + D0F53BF81E79593F00117362 /* AuthorizationSequenceSignUpControllerNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthorizationSequenceSignUpControllerNode.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 = ""; }; @@ -960,6 +1001,9 @@ D0F7AB341DCFADCD009AD9A1 /* ChatMessageBubbleImages.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMessageBubbleImages.swift; sourceTree = ""; }; D0F7AB381DCFF87B009AD9A1 /* ChatMessageDateHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMessageDateHeader.swift; sourceTree = ""; }; D0F917B41E0DA396003687E6 /* GenerateTextEntities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GenerateTextEntities.swift; sourceTree = ""; }; + D0FA0ABE1E76E17F005BB9B7 /* TwoStepVerificationPasswordEntryController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TwoStepVerificationPasswordEntryController.swift; sourceTree = ""; }; + D0FA0AC01E7725AA005BB9B7 /* TwoStepVerificationResetController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TwoStepVerificationResetController.swift; sourceTree = ""; }; + D0FA0AC41E77431A005BB9B7 /* InstalledStickerPacksController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InstalledStickerPacksController.swift; sourceTree = ""; }; D0FC407F1D5B8E7400261D9D /* TelegramUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TelegramUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D0FC40821D5B8E7400261D9D /* TelegramUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TelegramUI.h; sourceTree = ""; }; D0FC40831D5B8E7400261D9D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -1534,6 +1578,9 @@ D0C932391E0B4AC60074F044 /* Settings */ = { isa = PBXGroup; children = ( + D0FA0AC21E7742CE005BB9B7 /* Privacy and Security */, + D0C9323A1E0B4AD40074F044 /* Data and Storage */, + D0FA0AC31E7742EE005BB9B7 /* Stickers */, D01B279A1E39386C0022A4C0 /* SettingsController.swift */, D01B279C1E394A500022A4C0 /* NotificationsAndSounds.swift */, D0A749961E3AA25200AD786E /* NotificationSoundSelection.swift */, @@ -1541,10 +1588,10 @@ D0CE1BD21E51BC6100404327 /* DebugController.swift */, D03E5E081E55C49C0029569A /* DebugAccountsController.swift */, D0528E671E65CB2C00E2FEF5 /* UsernameSetupController.swift */, - D05A32DD1E6F0097002760B4 /* PrivacyAndSecurityController.swift */, - D05A32ED1E6F25A0002760B4 /* ItemListRecentSessionItem.swift */, - D05A32E91E6F143C002760B4 /* RecentSessionsController.swift */, - D05A32EB1E6F1462002760B4 /* BlockedPeersController.swift */, + D0A11BF91E7836C20081CE03 /* ChangePhoneNumberIntroController.swift */, + D0A11BFB1E7840750081CE03 /* ChangePhoneNumberController.swift */, + D0A11BFD1E7840A50081CE03 /* ChangePhoneNumberControllerNode.swift */, + D0F53BEB1E784DA900117362 /* ChangePhoneNumberCodeController.swift */, ); name = Settings; sourceTree = ""; @@ -1647,6 +1694,17 @@ name = "Peer Selection"; sourceTree = ""; }; + D0D748041E7AF62000F4B1F6 /* Stickers */ = { + isa = PBXGroup; + children = ( + D0D748051E7AF63800F4B1F6 /* StickerPackPreviewController.swift */, + D0D748071E7AF64400F4B1F6 /* StickerPackPreviewControllerNode.swift */, + D0D7480E1E7B1BD600F4B1F6 /* StickerPackPreviewGridItem.swift */, + D0BE383B1E7C3E51000079AF /* StickerPackGalleryController.swift */, + ); + name = Stickers; + sourceTree = ""; + }; D0DC35481DE366B4000195EB /* Commands */ = { isa = PBXGroup; children = ( @@ -1790,7 +1848,6 @@ children = ( D0B843CE1DA922AD005F29E1 /* PeerInfoEntries.swift */, D0B843CC1DA903BB005F29E1 /* PeerInfoController.swift */, - D0B843D01DA922D7005F29E1 /* UserInfoEntries.swift */, D0486F091E523C8500091F0C /* GroupInfoController.swift */, D03E5E0E1E55F8B90029569A /* ChannelVisibilityController.swift */, D0561DE71E574C3200E6B9E9 /* ChannelAdminsController.swift */, @@ -1805,6 +1862,15 @@ name = Controller; sourceTree = ""; }; + D0F53BF51E79592300117362 /* Sign In */ = { + isa = PBXGroup; + children = ( + D0F53BF61E79593500117362 /* AuthorizationSequenceSignUpController.swift */, + D0F53BF81E79593F00117362 /* AuthorizationSequenceSignUpControllerNode.swift */, + ); + name = "Sign In"; + sourceTree = ""; + }; D0F69CCE1D6B87950046BCD6 /* Files */ = { isa = PBXGroup; children = ( @@ -1960,6 +2026,7 @@ D04BB2B71E44E5CB00650E93 /* Phone Entry */, D04BB2BC1E44FD1300650E93 /* Code Entry */, D04BB2C11E45016800650E93 /* Password Entry */, + D0F53BF51E79592300117362 /* Sign In */, ); name = Authorization; sourceTree = ""; @@ -2008,6 +2075,8 @@ D02383761DDF16B2004018B6 /* ChatControllerTitlePanelNodeContainer.swift */, D00C7CE81E379B820080C3D5 /* ChatSecretAutoremoveTimerActionSheet.swift */, D0EE97191D88BCA0006C18E1 /* ChatInfo.swift */, + D0EF40DE1E73100D000DFCD4 /* ChatHistoryNavigationStack.swift */, + D01C2AA01E758F90001F6F9A /* NavigateToChatController.swift */, D0F69E181D6B8AD10046BCD6 /* Items */, D03ADB461D703250005A521C /* Interface State */, D03ADB491D704427005A521C /* Accessory Panels */, @@ -2090,6 +2159,7 @@ D0F69E4F1D6B8BC40046BCD6 /* Gallery */, D0F69E671D6B8C030046BCD6 /* Map Input */, D07827CC1E03F32C00071108 /* Instant Page */, + D0D748041E7AF62000F4B1F6 /* Stickers */, ); name = Media; sourceTree = ""; @@ -2144,7 +2214,6 @@ isa = PBXGroup; children = ( D0C932391E0B4AC60074F044 /* Settings */, - D0C9323A1E0B4AD40074F044 /* Data and Storage */, ); name = Settings; sourceTree = ""; @@ -2194,6 +2263,8 @@ D0DA44551E4E7F43005FDCA7 /* ShakeAnimation.swift */, D0E305A41E5B2BFB00D7A3A2 /* ValidateAddressNameInteractive.swift */, D05A32DB1E6EFCC2002760B4 /* NumericFormat.swift */, + D05B724F1E720597000BD3AD /* PresentationData.swift */, + D01C2AAC1E768404001F6F9A /* Markdown.swift */, ); name = Utils; sourceTree = ""; @@ -2211,6 +2282,33 @@ name = Resources; sourceTree = ""; }; + D0FA0AC21E7742CE005BB9B7 /* Privacy and Security */ = { + isa = PBXGroup; + children = ( + D05A32DD1E6F0097002760B4 /* PrivacyAndSecurityController.swift */, + D05A32ED1E6F25A0002760B4 /* ItemListRecentSessionItem.swift */, + D05A32E91E6F143C002760B4 /* RecentSessionsController.swift */, + D05A32EB1E6F1462002760B4 /* BlockedPeersController.swift */, + D05B724C1E720393000BD3AD /* SelectivePrivacySettingsController.swift */, + D0EF40DC1E72F00E000DFCD4 /* SelectivePrivacySettingsPeersController.swift */, + D01C2AAA1E75E010001F6F9A /* TwoStepVerificationUnlockController.swift */, + D0FA0ABE1E76E17F005BB9B7 /* TwoStepVerificationPasswordEntryController.swift */, + D0FA0AC01E7725AA005BB9B7 /* TwoStepVerificationResetController.swift */, + ); + name = "Privacy and Security"; + sourceTree = ""; + }; + D0FA0AC31E7742EE005BB9B7 /* Stickers */ = { + isa = PBXGroup; + children = ( + D0FA0AC41E77431A005BB9B7 /* InstalledStickerPacksController.swift */, + D0E23DD71E805E2600B9B6D2 /* FeaturedStickerPacksController.swift */, + D04791661E79A22000F18979 /* ItemListStickerPackItem.swift */, + D0E23DDC1E8081A200B9B6D2 /* ArhivedStickerPacksController.swift */, + ); + name = Stickers; + sourceTree = ""; + }; D0FC40751D5B8E7400261D9D = { isa = PBXGroup; children = ( @@ -2442,6 +2540,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D0BE383C1E7C3E51000079AF /* StickerPackGalleryController.swift in Sources */, D01749621E11DB240057C89A /* NetworkStatusTitleView.swift in Sources */, D0B417C31D7DE54E004562A4 /* ChatPresentationInterfaceState.swift in Sources */, D00B3F9E1E3A4847003872C3 /* ItemListSectionHeaderItem.swift in Sources */, @@ -2449,6 +2548,7 @@ D08775101E3F46A400A97350 /* ComposeController.swift in Sources */, D03ADB4D1D7045C9005A521C /* ChatInterfaceStateAccessoryPanels.swift in Sources */, D0F7AB391DCFF87B009AD9A1 /* ChatMessageDateHeader.swift in Sources */, + D0FA0ABF1E76E17F005BB9B7 /* TwoStepVerificationPasswordEntryController.swift in Sources */, D0DF0C9A1D81FF3F008AEB01 /* ChatInputContextPanelNode.swift in Sources */, D0D03B1B1DECB0FE00220C46 /* info.c in Sources */, D0215D4A1E041CAF001A0B1E /* InstantPageMediaItem.swift in Sources */, @@ -2456,6 +2556,7 @@ D050F2131E48B61500988324 /* PhoneInputNode.swift in Sources */, D08775141E3F4A7700A97350 /* ContactListNameIndexHeader.swift in Sources */, D0215D461E041851001A0B1E /* InstantPageTextItem.swift in Sources */, + D0EF40DF1E73100D000DFCD4 /* ChatHistoryNavigationStack.swift in Sources */, D021E0D21DB4147500C6B04F /* ChatInterfaceInputNodes.swift in Sources */, D0D2689D1D79D33E00C422DA /* ShareRecipientsActionSheetController.swift in Sources */, D0BC38811E40F1D80044D6FE /* ContactSelectionControllerNode.swift in Sources */, @@ -2464,6 +2565,7 @@ D0F69E741D6B8C340046BCD6 /* ContactsControllerNode.swift in Sources */, D07827C71E01CD5900071108 /* VerticalListContextResultsChatInputPanelButtonItem.swift in Sources */, D021E0E51DB55D0A00C6B04F /* ChatMediaInputStickerPackItem.swift in Sources */, + D0F53BEC1E784DA900117362 /* ChangePhoneNumberCodeController.swift in Sources */, D07CFF7F1DCA308500761F81 /* ChatListNodeLocation.swift in Sources */, D0F69EA21D6B8E380046BCD6 /* PhotoResources.swift in Sources */, D08C367F1DB66A820064C744 /* ChatMediaInputPanelEntries.swift in Sources */, @@ -2483,6 +2585,7 @@ D04BB3361E48797500650E93 /* program.c in Sources */, D07A7DA51D95783C005BCD27 /* ListMessageNode.swift in Sources */, D0C9323C1E0B4AE90074F044 /* DataAndStorageSettingsController.swift in Sources */, + D0FA0AC11E7725AA005BB9B7 /* TwoStepVerificationResetController.swift in Sources */, D04BB2C51E45022C00650E93 /* AuthorizationSequencePasswordEntryControllerNode.swift in Sources */, D01F66131DE8903300345CBE /* ChatTextInputAudioRecordingButton.swift in Sources */, D0F69E341D6B8B030046BCD6 /* ChatMessageForwardInfoNode.swift in Sources */, @@ -2502,6 +2605,7 @@ D0F69D521D6B87D30046BCD6 /* MediaPlayer.swift in Sources */, D0F7AB351DCFADCD009AD9A1 /* ChatMessageBubbleImages.swift in Sources */, D0F69E031D6B8A880046BCD6 /* ChatListItem.swift in Sources */, + D0A11BFE1E7840A50081CE03 /* ChangePhoneNumberControllerNode.swift in Sources */, D0F69E081D6B8A9C0046BCD6 /* ChatListSearchContainerNode.swift in Sources */, D0215D401E0410D9001A0B1E /* InstantPageLinkSelectionView.swift in Sources */, D04B66B81DD672D00049C3D2 /* GeoLocation.swift in Sources */, @@ -2533,6 +2637,7 @@ D02958021D6F0D5F00360E5E /* TapLongTapOrDoubleTapGestureRecognizer.swift in Sources */, D0D03B131DECB0FE00220C46 /* framing.c in Sources */, D0F69E651D6B8BF90046BCD6 /* ChatVideoGalleryItemScrubberView.swift in Sources */, + D0E23DD81E805E2600B9B6D2 /* FeaturedStickerPacksController.swift in Sources */, D03ADB4F1D70546B005A521C /* AccessoryPanelNode.swift in Sources */, D0613FC81E5F8AB100202CDB /* ChannelInfoController.swift in Sources */, D0F69E421D6B8B7E0046BCD6 /* ChatTextInputPanelNode.swift in Sources */, @@ -2561,6 +2666,7 @@ D0B843921DA7F13E005F29E1 /* ItemListDisclosureItem.swift in Sources */, D07CFF791DCA226F00761F81 /* ChatListNode.swift in Sources */, D0B7F8E81D8A1F5F0045D939 /* PeerMediaCollectionControllerNode.swift in Sources */, + D0FA0AC51E77431A005BB9B7 /* InstalledStickerPacksController.swift in Sources */, D0215D561E043020001A0B1E /* InstantPageControllerNode.swift in Sources */, D0B98E7F1E575D2C008084B1 /* ChannelBlacklistController.swift in Sources */, D0F69DD21D6B8A0D0046BCD6 /* SearchDisplayControllerContentNode.swift in Sources */, @@ -2582,6 +2688,7 @@ D0C932361E0988C60074F044 /* ChatButtonKeyboardInputNode.swift in Sources */, D04BB32E1E48797500650E93 /* buffer.c in Sources */, D0F69E591D6B8BDA0046BCD6 /* GalleryPagerNode.swift in Sources */, + D01C2AA11E758F90001F6F9A /* NavigateToChatController.swift in Sources */, D0F69E391D6B8B030046BCD6 /* ChatMessageMediaBubbleContentNode.swift in Sources */, D0F69D351D6B87D30046BCD6 /* MediaFrameSource.swift in Sources */, D0736F2A1DF4D5FF00F2C02A /* MediaNavigationAccessoryPanel.swift in Sources */, @@ -2604,14 +2711,17 @@ D08C36831DB66AD40064C744 /* ChatMediaInputStickerGridItem.swift in Sources */, D0F69E641D6B8BF90046BCD6 /* ChatVideoGalleryItem.swift in Sources */, D075518D1DDA4E0B0073E051 /* LegacyControllerNode.swift in Sources */, + D04791671E79A22000F18979 /* ItemListStickerPackItem.swift in Sources */, D0215D421E0411DB001A0B1E /* InstantPageLayoutSpacings.swift in Sources */, D0215D4E1E042164001A0B1E /* InstantPageWebEmbedNode.swift in Sources */, D07CFF7B1DCA24BF00761F81 /* ChatListNodeEntries.swift in Sources */, D0DF0CA11D821B28008AEB01 /* HashtagChatInputPanelItem.swift in Sources */, D021E0D01DB413BC00C6B04F /* ChatInputNode.swift in Sources */, D0F69E351D6B8B030046BCD6 /* ChatMessageInteractiveFileNode.swift in Sources */, + D0A11BFC1E7840750081CE03 /* ChangePhoneNumberController.swift in Sources */, D08775121E3F46AB00A97350 /* ComposeControllerNode.swift in Sources */, D01749531E1068820057C89A /* HashtagSearchControllerNode.swift in Sources */, + D0F53BF91E79593F00117362 /* AuthorizationSequenceSignUpControllerNode.swift in Sources */, D0F69E151D6B8ACF0046BCD6 /* ChatControllerNode.swift in Sources */, D02383731DDF0D8A004018B6 /* ChatInfoTitlePanelNode.swift in Sources */, D0568AAF1DF1B3920022E7DA /* HapticFeedback.swift in Sources */, @@ -2626,12 +2736,15 @@ D0F69E621D6B8BF90046BCD6 /* ChatHoleGalleryItem.swift in Sources */, D0F69E331D6B8B030046BCD6 /* ChatMessageFileBubbleContentNode.swift in Sources */, D04BB33A1E48797500650E93 /* shader.c in Sources */, + D05B72501E720597000BD3AD /* PresentationData.swift in Sources */, D0F69E461D6B8B950046BCD6 /* ChatHistoryNavigationButtonNode.swift in Sources */, D03ADB481D703268005A521C /* ChatInterfaceState.swift in Sources */, + D0D748061E7AF63800F4B1F6 /* StickerPackPreviewController.swift in Sources */, D0F69D671D6B87D30046BCD6 /* FFMpegPacket.swift in Sources */, D0F69E321D6B8B030046BCD6 /* ChatMessageDateAndStatusNode.swift in Sources */, D0F69E041D6B8A880046BCD6 /* ChatListSearchItem.swift in Sources */, D0F69E611D6B8BF90046BCD6 /* ChatDocumentGalleryItem.swift in Sources */, + D0D748081E7AF64400F4B1F6 /* StickerPackPreviewControllerNode.swift in Sources */, D0F69E0A1D6B8AA60046BCD6 /* ChatListSearchRecentPeersNode.swift in Sources */, D00C7CE61E378FD00080C3D5 /* RadialTimeoutNode.swift in Sources */, D0F69E3E1D6B8B030046BCD6 /* ChatUnreadItem.swift in Sources */, @@ -2664,6 +2777,7 @@ D08774F81E3DE7BF00A97350 /* ItemListEditableDeleteControlNode.swift in Sources */, D0E305AD1E5BA3E700D7A3A2 /* ItemListControllerEmptyStateItem.swift in Sources */, D0DF0C981D81FF28008AEB01 /* HashtagChatInputContextPanelNode.swift in Sources */, + D0E23DDD1E8081A200B9B6D2 /* ArhivedStickerPacksController.swift in Sources */, D0F69E731D6B8C340046BCD6 /* ContactsController.swift in Sources */, D0F69D261D6B87D30046BCD6 /* MediaManager.swift in Sources */, D01D6BFC1E42AB3C006151C6 /* EmojiUtils.swift in Sources */, @@ -2695,6 +2809,7 @@ D0F69DD11D6B8A0D0046BCD6 /* SearchDisplayController.swift in Sources */, D08775091E3E59DE00A97350 /* PeerNotificationSoundStrings.swift in Sources */, D0A749971E3AA25200AD786E /* NotificationSoundSelection.swift in Sources */, + D0EF40DD1E72F00E000DFCD4 /* SelectivePrivacySettingsPeersController.swift in Sources */, D0DE77271D932627002B8809 /* ChatHistoryNode.swift in Sources */, D07CFF7D1DCA273400761F81 /* ChatListViewTransition.swift in Sources */, D0DF0C951D81B063008AEB01 /* ChatInterfaceStateContextMenus.swift in Sources */, @@ -2718,6 +2833,7 @@ D04BB33C1E48797500650E93 /* timing.c in Sources */, D01B27A41E394FC90022A4C0 /* SecuritySettings.swift in Sources */, D050F2181E48D9EA00988324 /* AuthorizationSequenceCountrySelectionControllerNode.swift in Sources */, + D01C2AAD1E768404001F6F9A /* Markdown.swift in Sources */, D049EAF31E44DE2500A2CD3A /* AuthorizationSequenceController.swift in Sources */, D08774FA1E3E2A5600A97350 /* ItemListCheckboxItem.swift in Sources */, D073CE711DCBF23F007511FD /* DeclareEncodables.swift in Sources */, @@ -2727,10 +2843,11 @@ D05A32EA1E6F143C002760B4 /* RecentSessionsController.swift in Sources */, D0F69E7C1D6B8C470046BCD6 /* SettingsAccountInfoItem.swift in Sources */, D00C7CDE1E37770A0080C3D5 /* SecretMediaPreviewControllerNode.swift in Sources */, + D0D7480F1E7B1BD600F4B1F6 /* StickerPackPreviewGridItem.swift in Sources */, D01B27951E38F3BF0022A4C0 /* ItemListControllerNode.swift in Sources */, - D0B843D11DA922D7005F29E1 /* UserInfoEntries.swift in Sources */, D0F69E6A1D6B8C160046BCD6 /* MapInputController.swift in Sources */, D00219041DDCC86400BE708A /* PerformanceSpinner.swift in Sources */, + D01C2AAB1E75E010001F6F9A /* TwoStepVerificationUnlockController.swift in Sources */, D050F2161E48D9E000988324 /* AuthorizationSequenceCountrySelectionController.swift in Sources */, D0561DDF1E56FE8200E6B9E9 /* ItemListSingleLineInputItem.swift in Sources */, D01749551E1082770057C89A /* StoredMessageFromSearchPeer.swift in Sources */, @@ -2762,7 +2879,9 @@ D0E35A091DE4804900BC6096 /* VerticalListContextResultsChatInputPanelItem.swift in Sources */, D01AC9181DD5033100E8160F /* ChatMessageActionButtonsNode.swift in Sources */, D017494E1E1059570057C89A /* StringWithAppliedEntities.swift in Sources */, + D05B724D1E720393000BD3AD /* SelectivePrivacySettingsController.swift in Sources */, D0DE77231D932043002B8809 /* PeerMediaCollectionInterfaceState.swift in Sources */, + D0A11BFA1E7836C20081CE03 /* ChangePhoneNumberIntroController.swift in Sources */, D0F69D781D6B87DF0046BCD6 /* MediaTrackFrameBuffer.swift in Sources */, D01AC91F1DD5E09000E8160F /* EditAccessoryPanelNode.swift in Sources */, D05A32EC1E6F1462002760B4 /* BlockedPeersController.swift in Sources */, @@ -2814,6 +2933,7 @@ D0F69DCF1D6B8A0D0046BCD6 /* SearchBarNode.swift in Sources */, D0F69DE41D6B8A420046BCD6 /* ListControllerNode.swift in Sources */, D00E15261DDBD4E700ACF65C /* LegacyCamera.swift in Sources */, + D0F53BF71E79593500117362 /* AuthorizationSequenceSignUpController.swift in Sources */, D0528E6D1E65DE3B00E2FEF5 /* WebpagePreviewAccessoryPanelNode.swift in Sources */, D00219061DDD1C9E00BE708A /* ImageContainingNode.swift in Sources */, D0F69E2E1D6B8B030046BCD6 /* ChatMessageAvatarAccessoryItem.swift in Sources */, diff --git a/TelegramUI/ArhivedStickerPacksController.swift b/TelegramUI/ArhivedStickerPacksController.swift new file mode 100644 index 0000000000..505127a6ff --- /dev/null +++ b/TelegramUI/ArhivedStickerPacksController.swift @@ -0,0 +1,333 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private final class ArchivedStickerPacksControllerArguments { + let account: Account + + let openStickerPack: (StickerPackCollectionInfo) -> Void + let setPackIdWithRevealedOptions: (ItemCollectionId?, ItemCollectionId?) -> Void + let removePack: (StickerPackCollectionInfo) -> Void + + init(account: Account, openStickerPack: @escaping (StickerPackCollectionInfo) -> Void, setPackIdWithRevealedOptions: @escaping (ItemCollectionId?, ItemCollectionId?) -> Void, removePack: @escaping (StickerPackCollectionInfo) -> Void) { + self.account = account + self.openStickerPack = openStickerPack + self.setPackIdWithRevealedOptions = setPackIdWithRevealedOptions + self.removePack = removePack + } +} + +private enum ArchivedStickerPacksSection: Int32 { + case stickers +} + +private enum ArchivedStickerPacksEntryId: Hashable { + case index(Int32) + case pack(ItemCollectionId) + + var hashValue: Int { + switch self { + case let .index(index): + return index.hashValue + case let .pack(id): + return id.hashValue + } + } + + static func ==(lhs: ArchivedStickerPacksEntryId, rhs: ArchivedStickerPacksEntryId) -> Bool { + switch lhs { + case let .index(index): + if case .index(index) = rhs { + return true + } else { + return false + } + case let .pack(id): + if case .pack(id) = rhs { + return true + } else { + return false + } + } + } +} + +private enum ArchivedStickerPacksEntry: ItemListNodeEntry { + case info(String) + case pack(Int32, StickerPackCollectionInfo, StickerPackItem?, Int32, Bool, ItemListStickerPackItemEditing) + + var section: ItemListSectionId { + switch self { + case .info, .pack: + return ArchivedStickerPacksSection.stickers.rawValue + } + } + + var stableId: ArchivedStickerPacksEntryId { + switch self { + case .info: + return .index(0) + case let .pack(_, info, _, _, _, _): + return .pack(info.id) + } + } + + static func ==(lhs: ArchivedStickerPacksEntry, rhs: ArchivedStickerPacksEntry) -> Bool { + switch lhs { + case let .info(text): + if case .info(text) = rhs { + return true + } else { + return false + } + case let .pack(lhsIndex, lhsInfo, lhsTopItem, lhsCount, lhsEnabled, lhsEditing): + if case let .pack(rhsIndex, rhsInfo, rhsTopItem, rhsCount, rhsEnabled, rhsEditing) = rhs { + if lhsIndex != rhsIndex { + return false + } + if lhsInfo != rhsInfo { + return false + } + if lhsTopItem != rhsTopItem { + return false + } + if lhsCount != rhsCount { + return false + } + if lhsEnabled != rhsEnabled { + return false + } + if lhsEditing != rhsEditing { + return false + } + return true + } else { + return false + } + } + } + + static func <(lhs: ArchivedStickerPacksEntry, rhs: ArchivedStickerPacksEntry) -> Bool { + switch lhs { + case .info: + switch rhs { + case .info: + return false + default: + return true + } + case let .pack(lhsIndex, _, _, _, _, _): + switch rhs { + case let .pack(rhsIndex, _, _, _, _, _): + return lhsIndex < rhsIndex + default: + return false + } + } + } + + func item(_ arguments: ArchivedStickerPacksControllerArguments) -> ListViewItem { + switch self { + case let .info(text): + return ItemListTextItem(text: .plain(text), sectionId: self.section) + case let .pack(_, info, topItem, count, enabled, editing): + return ItemListStickerPackItem(account: arguments.account, packInfo: info, itemCount: count, topItem: topItem, unread: false, control: .none, editing: editing, enabled: enabled, sectionId: self.section, action: { _ in + arguments.openStickerPack(info) + }, setPackIdWithRevealedOptions: { current, previous in + arguments.setPackIdWithRevealedOptions(current, previous) + }, addPack: { + }, removePack: { + arguments.removePack(info) + }) + } + } +} + +private struct ArchivedStickerPacksControllerState: Equatable { + let editing: Bool + let packIdWithRevealedOptions: ItemCollectionId? + let removingPackIds: Set + + init() { + self.editing = false + self.packIdWithRevealedOptions = nil + self.removingPackIds = Set() + } + + init(editing: Bool, packIdWithRevealedOptions: ItemCollectionId?, removingPackIds: Set) { + self.editing = editing + self.packIdWithRevealedOptions = packIdWithRevealedOptions + self.removingPackIds = removingPackIds + } + + static func ==(lhs: ArchivedStickerPacksControllerState, rhs: ArchivedStickerPacksControllerState) -> Bool { + if lhs.editing != rhs.editing { + return false + } + if lhs.packIdWithRevealedOptions != rhs.packIdWithRevealedOptions { + return false + } + if lhs.removingPackIds != rhs.removingPackIds { + return false + } + + return true + } + + func withUpdatedEditing(_ editing: Bool) -> ArchivedStickerPacksControllerState { + return ArchivedStickerPacksControllerState(editing: editing, packIdWithRevealedOptions: self.packIdWithRevealedOptions, removingPackIds: self.removingPackIds) + } + + func withUpdatedPackIdWithRevealedOptions(_ packIdWithRevealedOptions: ItemCollectionId?) -> ArchivedStickerPacksControllerState { + return ArchivedStickerPacksControllerState(editing: self.editing, packIdWithRevealedOptions: packIdWithRevealedOptions, removingPackIds: self.removingPackIds) + } + + func withUpdatedRemovingPackIds(_ removingPackIds: Set) -> ArchivedStickerPacksControllerState { + return ArchivedStickerPacksControllerState(editing: editing, packIdWithRevealedOptions: self.self.packIdWithRevealedOptions, removingPackIds: removingPackIds) + } +} + +private func archivedStickerPacksControllerEntries(state: ArchivedStickerPacksControllerState, packs: [ArchivedStickerPackItem]?, installedView: CombinedView) -> [ArchivedStickerPacksEntry] { + var entries: [ArchivedStickerPacksEntry] = [] + + if let packs = packs { + entries.append(.info("You can have up to 200 sticker sets installed.\nUnused stickers are archived when you add more.\n\n")) + + var installedIds = Set() + if let view = installedView.views[.itemCollectionIds(namespaces: [Namespaces.ItemCollection.CloudStickerPacks])] as? ItemCollectionIdsView, let ids = view.idsByNamespace[Namespaces.ItemCollection.CloudStickerPacks] { + installedIds = ids + } + + var index: Int32 = 0 + for item in packs { + if !installedIds.contains(item.info.id) { + entries.append(.pack(index, item.info, item.topItems.first, item.info.count, !state.removingPackIds.contains(item.info.id), ItemListStickerPackItemEditing(editable: true, editing: state.editing, revealed: state.packIdWithRevealedOptions == item.info.id))) + index += 1 + } + } + } + + return entries +} + +public func archivedStickerPacksController(account: Account) -> ViewController { + let statePromise = ValuePromise(ArchivedStickerPacksControllerState(), ignoreRepeated: true) + let stateValue = Atomic(value: ArchivedStickerPacksControllerState()) + let updateState: ((ArchivedStickerPacksControllerState) -> ArchivedStickerPacksControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)? + + let actionsDisposable = DisposableSet() + + let resolveDisposable = MetaDisposable() + actionsDisposable.add(resolveDisposable) + + let removePackDisposables = DisposableDict() + actionsDisposable.add(removePackDisposables) + + let stickerPacks = Promise<[ArchivedStickerPackItem]?>() + stickerPacks.set(.single(nil) |> then(archivedStickerPacks(account: account) |> map { Optional($0) })) + + let installedStickerPacks = Promise() + installedStickerPacks.set(account.postbox.combinedView(keys: [.itemCollectionIds(namespaces: [Namespaces.ItemCollection.CloudStickerPacks])])) + + let arguments = ArchivedStickerPacksControllerArguments(account: account, openStickerPack: { info in + presentControllerImpl?(StickerPackPreviewController(account: account, stickerPack: .id(id: info.id.id, accessHash: info.accessHash)), ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + }, setPackIdWithRevealedOptions: { packId, fromPackId in + updateState { state in + if (packId == nil && fromPackId == state.packIdWithRevealedOptions) || (packId != nil && fromPackId == nil) { + return state.withUpdatedPackIdWithRevealedOptions(packId) + } else { + return state + } + } + }, removePack: { info in + var remove = false + updateState { state in + var removingPackIds = state.removingPackIds + if !removingPackIds.contains(info.id) { + removingPackIds.insert(info.id) + remove = true + } + return state.withUpdatedRemovingPackIds(removingPackIds) + } + if remove { + let applyPacks: Signal = stickerPacks.get() + |> filter { $0 != nil } + |> take(1) + |> deliverOnMainQueue + |> mapToSignal { packs -> Signal in + if let packs = packs { + var updatedPacks = packs + for i in 0 ..< updatedPacks.count { + if updatedPacks[i].info.id == info.id { + updatedPacks.remove(at: i) + break + } + } + stickerPacks.set(.single(updatedPacks)) + } + + return .complete() + } + removePackDisposables.set((removeArchivedStickerPack(account: account, info: info) |> then(applyPacks) |> deliverOnMainQueue).start(completed: { + updateState { state in + var removingPackIds = state.removingPackIds + removingPackIds.remove(info.id) + return state.withUpdatedRemovingPackIds(removingPackIds) + } + }), forKey: info.id) + } + }) + + var previousPackCount: Int? + + let signal = combineLatest(statePromise.get() |> deliverOnMainQueue, stickerPacks.get() |> deliverOnMainQueue, installedStickerPacks.get() |> deliverOnMainQueue) + |> map { state, packs, installedView -> (ItemListControllerState, (ItemListNodeState, ArchivedStickerPacksEntry.ItemGenerationArguments)) in + var rightNavigationButton: ItemListNavigationButton? + if let packs = packs, packs.count != 0 { + if state.editing { + rightNavigationButton = ItemListNavigationButton(title: "Done", style: .bold, enabled: true, action: { + updateState { + $0.withUpdatedEditing(false) + } + }) + } else { + rightNavigationButton = ItemListNavigationButton(title: "Edit", style: .regular, enabled: true, action: { + updateState { + $0.withUpdatedEditing(true) + } + }) + } + } + + let previous = previousPackCount + previousPackCount = packs?.count + + var emptyStateItem: ItemListControllerEmptyStateItem? + if packs == nil { + emptyStateItem = ItemListLoadingIndicatorEmptyStateItem() + } + + let controllerState = ItemListControllerState(title: "Archived Stickers", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) + + let listState = ItemListNodeState(entries: archivedStickerPacksControllerEntries(state: state, packs: packs, installedView: installedView), style: .blocks, emptyStateItem: emptyStateItem, animateChanges: previous != nil && packs != nil && (previous! != 0 && previous! >= packs!.count - 10)) + return (controllerState, (listState, arguments)) + } |> afterDisposed { + actionsDisposable.dispose() + } + + let controller = ItemListController(signal) + controller.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + presentControllerImpl = { [weak controller] c, p in + if let controller = controller { + controller.present(c, in: .window, with: p) + } + } + + return controller +} diff --git a/TelegramUI/AudioWaveform.swift b/TelegramUI/AudioWaveform.swift index 5ec0c7d992..5aafe6dffe 100644 --- a/TelegramUI/AudioWaveform.swift +++ b/TelegramUI/AudioWaveform.swift @@ -1,15 +1,24 @@ import Foundation -private func getBits(data: UnsafeRawPointer, bitOffset: Int, numBits: Int) -> Int32 { +private func getBits(data: UnsafeRawPointer, length: Int, bitOffset: Int, numBits: Int) -> Int32 { let normalizedNumBits = Int(pow(2.0, Double(numBits))) - 1 - let normalizedData = data.advanced(by: bitOffset / 8) + let byteOffset = bitOffset / 8 + let normalizedData = data.advanced(by: byteOffset) let normalizedBitOffset = bitOffset % 8 - return (normalizedData.assumingMemoryBound(to: Int32.self).pointee >> Int32(normalizedBitOffset)) & Int32(normalizedNumBits) + var value: Int32 = 0 + if byteOffset + 4 > length { + let remaining = length - byteOffset + withUnsafeMutableBytes(of: &value, { (bytes: UnsafeMutableRawBufferPointer) -> Void in + memcpy(bytes.baseAddress!, normalizedData, remaining) + }) + } else { + value = normalizedData.assumingMemoryBound(to: Int32.self).pointee + } + return (value >> Int32(normalizedBitOffset)) & Int32(normalizedNumBits) } private func setBits(data: UnsafeMutableRawPointer, bitOffset: Int, numBits: Int, value: Int32) { - let normalizedNumBits = Int(pow(2.0, Double(numBits))) - 1 let normalizedData = data.advanced(by: bitOffset / 8) let normalizedBitOffset = bitOffset % 8 @@ -31,12 +40,10 @@ final class AudioWaveform: Equatable { result.count = numSamples * 2 bitstream.withUnsafeBytes { (bytes: UnsafePointer) -> Void in - let maxSample = (1 << bitsPerSample) - 1 - result.withUnsafeMutableBytes { (samples: UnsafeMutablePointer) -> Void in let norm = Int64((1 << bitsPerSample) - 1) for i in 0 ..< numSamples { - samples[i] = Int16(Int64(getBits(data: bytes, bitOffset: i * 5, numBits: 5)) * norm / norm) + samples[i] = Int16(Int64(getBits(data: bytes, length: bitstream.count, bitOffset: i * 5, numBits: 5)) * norm / norm) } } } diff --git a/TelegramUI/AuthorizationSequenceCodeEntryControllerNode.swift b/TelegramUI/AuthorizationSequenceCodeEntryControllerNode.swift index cacd5b2897..8bdd841bc0 100644 --- a/TelegramUI/AuthorizationSequenceCodeEntryControllerNode.swift +++ b/TelegramUI/AuthorizationSequenceCodeEntryControllerNode.swift @@ -4,7 +4,7 @@ import Display import TelegramCore import SwiftSignalKit -private func currentOptionText(_ type: SentAuthorizationCodeType) -> NSAttributedString { +func authorizationCurrentOptionText(_ type: SentAuthorizationCodeType) -> NSAttributedString { switch type { case .sms: return NSAttributedString(string: "We have sent you an SMS with a code to the number", font: Font.regular(16.0), textColor: UIColor.black, paragraphAlignment: .center) @@ -22,7 +22,7 @@ private func currentOptionText(_ type: SentAuthorizationCodeType) -> NSAttribute } } -private func nextOptionText(_ type: AuthorizationCodeNextType?, timeout: Int32?) -> NSAttributedString { +func authorizationNextOptionText(_ type: AuthorizationCodeNextType?, timeout: Int32?) -> NSAttributedString { if let type = type, let timeout = timeout { let minutes = timeout / 60 let seconds = timeout % 60 @@ -101,7 +101,7 @@ final class AuthorizationSequenceCodeEntryControllerNode: ASDisplayNode, UITextF self.nextOptionNode = ASTextNode() self.nextOptionNode.isLayerBacked = true self.nextOptionNode.displaysAsynchronously = false - self.nextOptionNode.attributedText = nextOptionText(AuthorizationCodeNextType.call, timeout: 60) + self.nextOptionNode.attributedText = authorizationNextOptionText(AuthorizationCodeNextType.call, timeout: 60) self.codeSeparatorNode = ASDisplayNode() self.codeSeparatorNode.isLayerBacked = true @@ -140,14 +140,14 @@ final class AuthorizationSequenceCodeEntryControllerNode: ASDisplayNode, UITextF self.codeType = codeType self.phoneNumber = number - self.currentOptionNode.attributedText = currentOptionText(codeType) + self.currentOptionNode.attributedText = authorizationCurrentOptionText(codeType) if let timeout = timeout { self.currentTimeoutTime = timeout let disposable = ((Signal.single(1) |> delay(1.0, queue: Queue.mainQueue())) |> restart).start(next: { [weak self] _ in if let strongSelf = self { if let currentTimeoutTime = strongSelf.currentTimeoutTime, currentTimeoutTime > 0 { strongSelf.currentTimeoutTime = currentTimeoutTime - 1 - strongSelf.nextOptionNode.attributedText = nextOptionText(nextType, timeout:strongSelf.currentTimeoutTime) + strongSelf.nextOptionNode.attributedText = authorizationNextOptionText(nextType, timeout:strongSelf.currentTimeoutTime) if let layoutArguments = strongSelf.layoutArguments { strongSelf.containerLayoutUpdated(layoutArguments.0, navigationBarHeight: layoutArguments.1, transition: .immediate) } @@ -162,7 +162,7 @@ final class AuthorizationSequenceCodeEntryControllerNode: ASDisplayNode, UITextF self.currentTimeoutTime = nil self.countdownDisposable.set(nil) } - self.nextOptionNode.attributedText = nextOptionText(nextType, timeout: self.currentTimeoutTime) + self.nextOptionNode.attributedText = authorizationNextOptionText(nextType, timeout: self.currentTimeoutTime) } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { diff --git a/TelegramUI/AuthorizationSequenceController.swift b/TelegramUI/AuthorizationSequenceController.swift index f5ecc87eb7..3ef8785b77 100644 --- a/TelegramUI/AuthorizationSequenceController.swift +++ b/TelegramUI/AuthorizationSequenceController.swift @@ -192,6 +192,53 @@ public final class AuthorizationSequenceController: NavigationController { return controller } + private func signUpController(firstName: String, lastName: String) -> AuthorizationSequenceSignUpController { + var currentController: AuthorizationSequenceSignUpController? + for c in self.viewControllers { + if let c = c as? AuthorizationSequenceSignUpController { + currentController = c + break + } + } + let controller: AuthorizationSequenceSignUpController + if let currentController = currentController { + controller = currentController + } else { + controller = AuthorizationSequenceSignUpController() + controller.signUpWithName = { [weak self, weak controller] firstName, lastName in + if let strongSelf = self { + controller?.inProgress = true + + strongSelf.actionDisposable.set((signUpWithName(account: strongSelf.account, firstName: firstName, lastName: lastName) |> deliverOnMainQueue).start(error: { error in + Queue.mainQueue().async { + if let controller = controller { + controller.inProgress = false + + let text: String + switch error { + case .limitExceeded: + text = "You have entered invalid password too many times. Please try again later." + case .codeExpired: + text = "Authorization code has expired. Please start again." + case .invalidFirstName: + text = "Please enter valid first name" + case .invalidLastName: + text = "Please enter valid last name" + case .generic: + text = "An error occured. Please try again later." + } + + controller.present(standardTextAlertController(title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: "OK", action: {})]), in: .window) + } + } + })) + } + } + } + controller.updateData(firstName: firstName, lastName: lastName) + return controller + } + private func updateState(state: Coding?) { if let state = state as? UnauthorizedAccountState { switch state.contents { @@ -206,9 +253,11 @@ public final class AuthorizationSequenceController: NavigationController { self.setViewControllers([self.splashController(), self.codeEntryController(number: number, type: type, nextType: nextType, timeout: timeout)], animated: !self.viewControllers.isEmpty) case let .passwordEntry(hint): self.setViewControllers([self.splashController(), self.passwordEntryController(hint: hint)], animated: !self.viewControllers.isEmpty) + case let .signUp(_, _, _, firstName, lastName): + self.setViewControllers([self.splashController(), self.signUpController(firstName: firstName, lastName: lastName)], animated: !self.viewControllers.isEmpty) } } else if let _ = state as? AuthorizedAccountState { - self._authorizedAccount.set(accountWithId(self.account.id, appGroupPath: self.account.appGroupPath, testingEnvironment: self.account.testingEnvironment) |> mapToSignal { account -> Signal in + self._authorizedAccount.set(accountWithId(apiId: self.account.apiId, id: self.account.id, appGroupPath: self.account.appGroupPath, testingEnvironment: self.account.testingEnvironment) |> mapToSignal { account -> Signal in if case let .right(authorizedAccount) = account { return .single(authorizedAccount) } else { diff --git a/TelegramUI/AuthorizationSequenceSignUpController.swift b/TelegramUI/AuthorizationSequenceSignUpController.swift new file mode 100644 index 0000000000..c707f9875b --- /dev/null +++ b/TelegramUI/AuthorizationSequenceSignUpController.swift @@ -0,0 +1,81 @@ +import Foundation +import Display +import AsyncDisplayKit + +final class AuthorizationSequenceSignUpController: ViewController { + private var controllerNode: AuthorizationSequenceSignUpControllerNode { + return self.displayNode as! AuthorizationSequenceSignUpControllerNode + } + + var initialName: (String, String) = ("", "") + var signUpWithName: ((String, String) -> Void)? + + private let hapticFeedback = HapticFeedback() + + var inProgress: Bool = false { + didSet { + if self.inProgress { + let item = UIBarButtonItem(customDisplayNode: ProgressNavigationButtonNode()) + self.navigationItem.rightBarButtonItem = item + } else { + self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Next", style: .done, target: self, action: #selector(self.nextPressed)) + } + self.controllerNode.inProgress = self.inProgress + } + } + + override init(navigationBar: NavigationBar = NavigationBar()) { + super.init(navigationBar: navigationBar) + + self.navigationBar.backgroundColor = nil + self.navigationBar.isOpaque = false + self.navigationBar.stripeColor = UIColor.clear + + self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Next", style: .done, target: self, action: #selector(self.nextPressed)) + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func loadDisplayNode() { + self.displayNode = AuthorizationSequenceSignUpControllerNode() + self.displayNodeDidLoad() + + self.controllerNode.signUpWithName = { [weak self] _ in + self?.nextPressed() + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.controllerNode.activateInput() + } + + func updateData(firstName: String, lastName: String) { + if self.isNodeLoaded { + if (firstName, lastName) != self.controllerNode.currentName { + self.controllerNode.updateData(firstName: firstName, lastName: lastName) + } + } else { + self.initialName = (firstName, lastName) + } + } + + override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: 0.0, transition: transition) + } + + @objc func nextPressed() { + if self.controllerNode.currentName.0.isEmpty { + hapticFeedback.error() + self.controllerNode.animateError() + } else { + let name = self.controllerNode.currentName + self.signUpWithName?(name.0, name.1) + } + } +} diff --git a/TelegramUI/AuthorizationSequenceSignUpControllerNode.swift b/TelegramUI/AuthorizationSequenceSignUpControllerNode.swift new file mode 100644 index 0000000000..43614bf133 --- /dev/null +++ b/TelegramUI/AuthorizationSequenceSignUpControllerNode.swift @@ -0,0 +1,213 @@ +import Foundation +import AsyncDisplayKit +import Display + +final class AuthorizationSequenceSignUpControllerNode: ASDisplayNode, UITextFieldDelegate { + private let navigationBackgroundNode: ASDisplayNode + private let stripeNode: ASDisplayNode + private let titleNode: ASTextNode + private let currentOptionNode: ASTextNode + + private let firstNameField: TextFieldNode + private let lastNameField: TextFieldNode + private let firstSeparatorNode: ASDisplayNode + private let lastSeparatorNode: ASDisplayNode + private let addPhotoButton: HighlightableButtonNode + + private var layoutArguments: (ContainerViewLayout, CGFloat)? + + var currentName: (String, String) { + return (self.firstNameField.textField.text ?? "", self.lastNameField.textField.text ?? "") + } + + var signUpWithName: ((String, String) -> Void)? + var requestNextOption: (() -> Void)? + + var inProgress: Bool = false { + didSet { + self.firstNameField.alpha = self.inProgress ? 0.6 : 1.0 + self.lastNameField.alpha = self.inProgress ? 0.6 : 1.0 + } + } + + override init() { + self.navigationBackgroundNode = ASDisplayNode() + self.navigationBackgroundNode.isLayerBacked = true + self.navigationBackgroundNode.backgroundColor = UIColor(0xefefef) + + self.stripeNode = ASDisplayNode() + self.stripeNode.isLayerBacked = true + self.stripeNode.backgroundColor = UIColor(0xbcbbc1) + + self.titleNode = ASTextNode() + self.titleNode.isLayerBacked = true + self.titleNode.displaysAsynchronously = false + self.titleNode.attributedText = NSAttributedString(string: "Your Info", font: Font.light(30.0), textColor: UIColor.black) + + self.currentOptionNode = ASTextNode() + self.currentOptionNode.isLayerBacked = true + self.currentOptionNode.displaysAsynchronously = false + self.currentOptionNode.attributedText = NSAttributedString(string: "Enter your name and add a profile picture", font: Font.regular(16.0), textColor: UIColor(0x878787), paragraphAlignment: .center) + + self.firstSeparatorNode = ASDisplayNode() + self.firstSeparatorNode.isLayerBacked = true + self.firstSeparatorNode.backgroundColor = UIColor(0xbcbbc1) + + self.lastSeparatorNode = ASDisplayNode() + self.lastSeparatorNode.isLayerBacked = true + self.lastSeparatorNode.backgroundColor = UIColor(0xbcbbc1) + + self.firstNameField = TextFieldNode() + self.firstNameField.textField.font = Font.regular(20.0) + self.firstNameField.textField.textAlignment = .natural + self.firstNameField.textField.returnKeyType = .next + self.firstNameField.textField.attributedPlaceholder = NSAttributedString(string: "First name", font: self.firstNameField.textField.font, textColor: UIColor(0xbcbcc3)) + + self.lastNameField = TextFieldNode() + self.lastNameField.textField.font = Font.regular(20.0) + self.lastNameField.textField.textAlignment = .natural + self.lastNameField.textField.returnKeyType = .done + self.lastNameField.textField.attributedPlaceholder = NSAttributedString(string: "Last name", font: self.lastNameField.textField.font, textColor: UIColor(0xbcbcc3)) + + self.addPhotoButton = HighlightableButtonNode() + self.addPhotoButton.setAttributedTitle(NSAttributedString(string: "add\nphoto", font: Font.regular(16.0), textColor: UIColor(0xbcbcc3), paragraphAlignment: .center), for: .normal) + self.addPhotoButton.setBackgroundImage(generateCircleImage(diameter: 110.0, lineWidth: 1.0, color: UIColor(0xbcbcc3)), for: .normal) + + super.init(viewBlock: { + return UITracingLayerView() + }, didLoad: nil) + + self.backgroundColor = UIColor.white + + self.firstNameField.textField.delegate = self + self.lastNameField.textField.delegate = self + + self.addSubnode(self.navigationBackgroundNode) + self.addSubnode(self.stripeNode) + self.addSubnode(self.firstSeparatorNode) + self.addSubnode(self.lastSeparatorNode) + self.addSubnode(self.firstNameField) + self.addSubnode(self.lastNameField) + self.addSubnode(self.titleNode) + self.addSubnode(self.currentOptionNode) + self.addSubnode(self.addPhotoButton) + + self.addPhotoButton.addTarget(self, action: #selector(self.addPhotoPressed), forControlEvents: .touchUpInside) + } + + func updateData(firstName: String, lastName: String) { + self.firstNameField.textField.attributedPlaceholder = NSAttributedString(string: firstName, font: Font.regular(20.0), textColor: UIColor(0xbcbcc3)) + self.lastNameField.textField.attributedPlaceholder = NSAttributedString(string: lastName, font: Font.regular(20.0), textColor: UIColor(0xbcbcc3)) + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + self.layoutArguments = (layout, navigationBarHeight) + + let insets = layout.insets(options: [.statusBar, .input]) + let availableHeight = max(1.0, layout.size.height - insets.top - insets.bottom) + + if max(layout.size.width, layout.size.height) > 1023.0 { + self.titleNode.attributedText = NSAttributedString(string: "Your Info", font: Font.light(40.0), textColor: UIColor.black) + } else { + self.titleNode.attributedText = NSAttributedString(string: "Your Info", font: Font.light(30.0), textColor: UIColor.black) + } + + let titleSize = self.titleNode.measure(CGSize(width: layout.size.width, height: CGFloat.greatestFiniteMagnitude)) + let additionalTitleSpacing: CGFloat + if titleSize.width > layout.size.width - 160.0 { + additionalTitleSpacing = 44.0 + } else { + additionalTitleSpacing = 0.0 + } + + let minimalTitleSpacing: CGFloat = 10.0 + let maxTitleSpacing: CGFloat = 22.0 + let fieldHeight: CGFloat = 57.0 + let inputFieldsHeight: CGFloat = fieldHeight * 2.0 + let leftInset: CGFloat = 130.0 + + let minimalNoticeSpacing: CGFloat = 11.0 + let maxNoticeSpacing: CGFloat = 35.0 + let noticeSize = self.currentOptionNode.measure(CGSize(width: layout.size.width - 28.0, height: CGFloat.greatestFiniteMagnitude)) + let minimalTermsOfServiceSpacing: CGFloat = 6.0 + let maxTermsOfServiceSpacing: CGFloat = 20.0 + let minTrailingSpacing: CGFloat = 10.0 + + let inputHeight = inputFieldsHeight + let essentialHeight = additionalTitleSpacing + titleSize.height + minimalTitleSpacing + inputHeight + minimalNoticeSpacing + noticeSize.height + let additionalHeight = minimalTermsOfServiceSpacing + minTrailingSpacing + + let navigationHeight: CGFloat + if essentialHeight + additionalHeight > availableHeight || availableHeight * 0.66 - inputHeight < additionalHeight { + navigationHeight = min(floor(availableHeight * 0.3), availableHeight - inputFieldsHeight) + } else { + navigationHeight = floor(availableHeight * 0.3) + } + + transition.updateFrame(node: self.navigationBackgroundNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: layout.size.width, height: navigationHeight))) + transition.updateFrame(node: self.stripeNode, frame: CGRect(origin: CGPoint(x: 0.0, y: navigationHeight), size: CGSize(width: layout.size.width, height: UIScreenPixel))) + + let titleOffset: CGFloat + if navigationHeight * 0.5 < titleSize.height + minimalTitleSpacing { + titleOffset = floor((navigationHeight - titleSize.height) / 2.0) + } else { + titleOffset = max(navigationHeight * 0.5, navigationHeight - maxTitleSpacing - titleSize.height) + } + transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - titleSize.width) / 2.0), y: titleOffset), size: titleSize)) + + let addPhotoButtonFrame = CGRect(origin: CGPoint(x: 10.0, y: navigationHeight + 10.0), size: CGSize(width: 110.0, height: 110.0)) + transition.updateFrame(node: self.addPhotoButton, frame: addPhotoButtonFrame) + + let firstFieldFrame = CGRect(origin: CGPoint(x: leftInset, y: navigationHeight + 3.0), size: CGSize(width: layout.size.width - leftInset, height: fieldHeight)) + transition.updateFrame(node: self.firstNameField, frame: firstFieldFrame) + + let lastFieldFrame = CGRect(origin: CGPoint(x: firstFieldFrame.minX, y: firstFieldFrame.maxY), size: CGSize(width: firstFieldFrame.size.width, height: fieldHeight)) + transition.updateFrame(node: self.lastNameField, frame: lastFieldFrame) + + transition.updateFrame(node: self.firstSeparatorNode, frame: CGRect(origin: CGPoint(x: leftInset, y: firstFieldFrame.maxY), size: CGSize(width: layout.size.width - leftInset, height: UIScreenPixel))) + transition.updateFrame(node: self.lastSeparatorNode, frame: CGRect(origin: CGPoint(x: leftInset, y: lastFieldFrame.maxY), size: CGSize(width: layout.size.width - leftInset, height: UIScreenPixel))) + + let additionalAvailableHeight = max(1.0, availableHeight - lastFieldFrame.maxY) + let additionalAvailableSpacing = max(1.0, additionalAvailableHeight - noticeSize.height) + let noticeSpacingFactor = maxNoticeSpacing / (maxNoticeSpacing + maxTermsOfServiceSpacing + minTrailingSpacing) + let termsOfServiceSpacingFactor = maxTermsOfServiceSpacing / (maxNoticeSpacing + maxTermsOfServiceSpacing + minTrailingSpacing) + + let noticeSpacing: CGFloat + let termsOfServiceSpacing: CGFloat + if additionalAvailableHeight <= maxNoticeSpacing + noticeSize.height + maxTermsOfServiceSpacing + minTrailingSpacing { + termsOfServiceSpacing = min(floor(termsOfServiceSpacingFactor * additionalAvailableSpacing), maxTermsOfServiceSpacing) + noticeSpacing = floor((additionalAvailableHeight - termsOfServiceSpacing - noticeSize.height) / 2.0) + } else { + noticeSpacing = min(floor(noticeSpacingFactor * additionalAvailableSpacing), maxNoticeSpacing) + termsOfServiceSpacing = min(floor(termsOfServiceSpacingFactor * additionalAvailableSpacing), maxTermsOfServiceSpacing) + } + + let currentOptionFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - noticeSize.width) / 2.0), y: lastFieldFrame.maxY + noticeSpacing), size: noticeSize) + + transition.updateFrame(node: self.currentOptionNode, frame: currentOptionFrame) + } + + func activateInput() { + self.firstNameField.textField.becomeFirstResponder() + } + + func animateError() { + if self.firstNameField.textField.text == nil || self.firstNameField.textField.text!.isEmpty { + self.firstNameField.layer.addShakeAnimation() + } + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if textField === self.firstNameField.textField { + self.lastNameField.textField.becomeFirstResponder() + } else { + let name = self.currentName + self.signUpWithName?(name.0, name.1) + } + return false + } + + @objc func addPhotoPressed() { + + } +} diff --git a/TelegramUI/CachedResourceRepresentations.swift b/TelegramUI/CachedResourceRepresentations.swift index 996e6ab7b6..2cc821ef9f 100644 --- a/TelegramUI/CachedResourceRepresentations.swift +++ b/TelegramUI/CachedResourceRepresentations.swift @@ -25,3 +25,23 @@ final class CachedStickerAJpegRepresentation: CachedMediaResourceRepresentation } } } + +final class CachedScaledImageRepresentation: CachedMediaResourceRepresentation { + let size: CGSize + + var uniqueId: String { + return "scaled-image-\(Int(self.size.width))x\(Int(self.size.height))" + } + + init(size: CGSize) { + self.size = size + } + + func isEqual(to: CachedMediaResourceRepresentation) -> Bool { + if let to = to as? CachedScaledImageRepresentation { + return self.size == to.size + } else { + return false + } + } +} diff --git a/TelegramUI/ChangePhoneNumberCodeController.swift b/TelegramUI/ChangePhoneNumberCodeController.swift new file mode 100644 index 0000000000..89d253f1ad --- /dev/null +++ b/TelegramUI/ChangePhoneNumberCodeController.swift @@ -0,0 +1,299 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private final class ChangePhoneNumberCodeControllerArguments { + let updateEntryText: (String) -> Void + let next: () -> Void + + init(updateEntryText: @escaping (String) -> Void, next: @escaping () -> Void) { + self.updateEntryText = updateEntryText + self.next = next + } +} + +private enum ChangePhoneNumberCodeSection: Int32 { + case code +} + +private enum ChangePhoneNumberCodeTag: ItemListItemTag { + case input + + func isEqual(to other: ItemListItemTag) -> Bool { + if let other = other as? ChangePhoneNumberCodeTag { + switch self { + case .input: + if case .input = other { + return true + } else { + return false + } + } + } else { + return false + } + } +} + +private enum ChangePhoneNumberCodeEntry: ItemListNodeEntry { + case codeEntry(String) + case codeInfo(String) + + var section: ItemListSectionId { + return ChangePhoneNumberCodeSection.code.rawValue + } + + var stableId: Int32 { + switch self { + case .codeEntry: + return 1 + case .codeInfo: + return 2 + } + } + + static func ==(lhs: ChangePhoneNumberCodeEntry, rhs: ChangePhoneNumberCodeEntry) -> Bool { + switch lhs { + case let .codeEntry(text): + if case .codeEntry(text) = rhs { + return true + } else { + return false + } + case let .codeInfo(text): + if case .codeInfo(text) = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: ChangePhoneNumberCodeEntry, rhs: ChangePhoneNumberCodeEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(_ arguments: ChangePhoneNumberCodeControllerArguments) -> ListViewItem { + switch self { + case let .codeEntry(text): + return ItemListSingleLineInputItem(title: NSAttributedString(string: "Code", textColor: .black), text: text, placeholder: "", type: .number, spacing: 10.0, tag: ChangePhoneNumberCodeTag.input, sectionId: self.section, textUpdated: { updatedText in + arguments.updateEntryText(updatedText) + }, action: { + arguments.next() + }) + case let .codeInfo(text): + return ItemListTextItem(text: .plain(text), sectionId: self.section) + } + } +} + +private struct ChangePhoneNumberCodeControllerState: Equatable { + let codeText: String + let checking: Bool + + init(codeText: String, checking: Bool) { + self.codeText = codeText + self.checking = checking + } + + static func ==(lhs: ChangePhoneNumberCodeControllerState, rhs: ChangePhoneNumberCodeControllerState) -> Bool { + if lhs.codeText != rhs.codeText { + return false + } + if lhs.checking != rhs.checking { + return false + } + + return true + } + + func withUpdatedCodeText(_ codeText: String) -> ChangePhoneNumberCodeControllerState { + return ChangePhoneNumberCodeControllerState(codeText: codeText, checking: self.checking) + } + + func withUpdatedChecking(_ checking: Bool) -> ChangePhoneNumberCodeControllerState { + return ChangePhoneNumberCodeControllerState(codeText: self.codeText, checking: checking) + } + + func withUpdatedNextMethodTimeout(_ nextMethodTimeout: Int32?) -> ChangePhoneNumberCodeControllerState { + return ChangePhoneNumberCodeControllerState(codeText: self.codeText, checking: self.checking) + } + + func withUpdatedCodeData(_ codeData: ChangeAccountPhoneNumberData) -> ChangePhoneNumberCodeControllerState { + return ChangePhoneNumberCodeControllerState(codeText: self.codeText, checking: self.checking) + } +} + +private func changePhoneNumberCodeControllerEntries(state: ChangePhoneNumberCodeControllerState, codeData: ChangeAccountPhoneNumberData, timeout: Int32?) -> [ChangePhoneNumberCodeEntry] { + var entries: [ChangePhoneNumberCodeEntry] = [] + + entries.append(.codeEntry(state.codeText)) + var text = authorizationCurrentOptionText(codeData.type).string + if let nextType = codeData.nextType { + text += "\n\n" + authorizationNextOptionText(nextType, timeout: timeout).string + } + entries.append(.codeInfo(text)) + + return entries +} + +private func timeoutSignal(codeData: ChangeAccountPhoneNumberData) -> Signal { + if let _ = codeData.nextType, let timeout = codeData.timeout { + return Signal { subscriber in + let value = Atomic(value: timeout) + subscriber.putNext(timeout) + + let timer = SwiftSignalKit.Timer(timeout: 1.0, repeat: true, completion: { + subscriber.putNext(value.modify { value in + return max(0, value - 1) + }) + }, queue: Queue.mainQueue()) + timer.start() + + return ActionDisposable { + timer.invalidate() + } + } + } else { + return .single(nil) + } +} + +func changePhoneNumberCodeController(account: Account, phoneNumber: String, codeData: ChangeAccountPhoneNumberData) -> ViewController { + let initialState = ChangePhoneNumberCodeControllerState(codeText: "", checking: false) + + let statePromise = ValuePromise(initialState, ignoreRepeated: true) + let stateValue = Atomic(value: initialState) + let updateState: ((ChangePhoneNumberCodeControllerState) -> ChangePhoneNumberCodeControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var dismissImpl: (() -> Void)? + var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments) -> Void)? + + let actionsDisposable = DisposableSet() + + let changePhoneDisposable = MetaDisposable() + actionsDisposable.add(changePhoneDisposable) + + let nextTypeDisposable = MetaDisposable() + actionsDisposable.add(nextTypeDisposable) + + let currentDataPromise = Promise() + currentDataPromise.set(.single(codeData)) + + let timeout = Promise() + timeout.set(timeoutSignal(codeData: codeData)) + + let resendCode = currentDataPromise.get() + |> mapToSignal { [weak currentDataPromise] data -> Signal in + if let _ = data.nextType { + return timeout.get() + |> filter { $0 == 0 } + |> take(1) + |> mapToSignal { _ -> Signal in + return Signal { subscriber in + return requestNextChangeAccountPhoneNumberVerification(account: account, phoneNumber: phoneNumber, phoneCodeHash: data.hash).start(next: { next in + currentDataPromise?.set(.single(next)) + }, error: { error in + + }) + } + } + } else { + return .complete() + } + } + nextTypeDisposable.set(resendCode.start()) + + let checkCode: () -> Void = { + var code: String? + updateState { state in + if state.checking || state.codeText.isEmpty { + return state + } else { + code = state.codeText + return state.withUpdatedChecking(true) + } + } + if let code = code { + changePhoneDisposable.set((requestChangeAccountPhoneNumber(account: account, phoneNumber: phoneNumber, phoneCodeHash: codeData.hash, phoneCode: code) |> deliverOnMainQueue).start(error: { error in + updateState { + return $0.withUpdatedChecking(false) + } + let alertText: String + switch error { + case .generic: + alertText = "An error occurred." + case .invalidCode: + alertText = "Invalid code. Please try again." + case .codeExpired: + alertText = "Code expired." + case .limitExceeded: + alertText = "You have entered invalid code too many times. Please try again later." + } + presentControllerImpl?(standardTextAlertController(title: nil, text: alertText, actions: [TextAlertAction(type: .defaultAction, title: "OK", action: {})]), ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + }, completed: { + updateState { + return $0.withUpdatedChecking(false) + } + presentControllerImpl?(standardTextAlertController(title: nil, text: "You have changed your phone number to \(formatPhoneNumber(phoneNumber)).", actions: [TextAlertAction(type: .defaultAction, title: "OK", action: {})]), ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + dismissImpl?() + })) + } + } + + let arguments = ChangePhoneNumberCodeControllerArguments(updateEntryText: { updatedText in + var initiateCheck = false + updateState { state in + if state.codeText.characters.count < 5 && updatedText.characters.count == 5 { + initiateCheck = true + } + return state.withUpdatedCodeText(updatedText) + } + if initiateCheck { + checkCode() + } + }, next: { + checkCode() + }) + + let signal = combineLatest(statePromise.get() |> deliverOnMainQueue, currentDataPromise.get() |> deliverOnMainQueue, timeout.get() |> deliverOnMainQueue) + |> map { state, data, timeout -> (ItemListControllerState, (ItemListNodeState, ChangePhoneNumberCodeEntry.ItemGenerationArguments)) in + var rightNavigationButton: ItemListNavigationButton? + if state.checking { + rightNavigationButton = ItemListNavigationButton(title: "", style: .activity, enabled: true, action: {}) + } else { + var nextEnabled = true + if state.codeText.isEmpty { + nextEnabled = false + } + rightNavigationButton = ItemListNavigationButton(title: "Next", style: .bold, enabled: nextEnabled, action: { + checkCode() + }) + } + + let controllerState = ItemListControllerState(title: formatPhoneNumber(phoneNumber), leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: false) + let listState = ItemListNodeState(entries: changePhoneNumberCodeControllerEntries(state: state, codeData: data, timeout: timeout), style: .blocks, focusItemTag: ChangePhoneNumberCodeTag.input, emptyStateItem: nil, animateChanges: false) + + return (controllerState, (listState, arguments)) + } |> afterDisposed { + actionsDisposable.dispose() + } + + let controller = ItemListController(signal) + controller.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + + presentControllerImpl = { [weak controller] c, p in + if let controller = controller { + controller.present(c, in: .window, with: p) + } + } + dismissImpl = { [weak controller] in + (controller?.navigationController as? NavigationController)?.popToRoot(animated: true) + } + + return controller +} diff --git a/TelegramUI/ChangePhoneNumberController.swift b/TelegramUI/ChangePhoneNumberController.swift new file mode 100644 index 0000000000..74cb82949f --- /dev/null +++ b/TelegramUI/ChangePhoneNumberController.swift @@ -0,0 +1,128 @@ +import Foundation +import Display +import AsyncDisplayKit +import TelegramCore +import SwiftSignalKit + +final class ChangePhoneNumberController: ViewController { + private var controllerNode: ChangePhoneNumberControllerNode { + return self.displayNode as! ChangePhoneNumberControllerNode + } + + private let account: Account + + private var currentData: (Int32, String)? + private let requestDisposable = MetaDisposable() + + var inProgress: Bool = false { + didSet { + if self.inProgress { + let item = UIBarButtonItem(customDisplayNode: ProgressNavigationButtonNode()) + self.navigationItem.rightBarButtonItem = item + } else { + self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Next", style: .done, target: self, action: #selector(self.nextPressed)) + } + self.controllerNode.inProgress = self.inProgress + } + } + var loginWithNumber: ((String) -> Void)? + + private let hapticFeedback = HapticFeedback() + + init(account: Account) { + self.account = account + + super.init(navigationBar: NavigationBar()) + + self.title = "Change Number" + self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Next", style: .done, target: self, action: #selector(self.nextPressed)) + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.requestDisposable.dispose() + } + + func updateData(countryCode: Int32, number: String) { + if self.currentData == nil || self.currentData! != (countryCode, number) { + self.currentData = (countryCode, number) + if self.isNodeLoaded { + self.controllerNode.codeAndNumber = (countryCode, number) + } + } + } + + override public func loadDisplayNode() { + self.displayNode = ChangePhoneNumberControllerNode() + self.displayNodeDidLoad() + self.controllerNode.selectCountryCode = { [weak self] in + if let strongSelf = self { + let controller = AuthorizationSequenceCountrySelectionController() + controller.completeWithCountryCode = { code in + if let strongSelf = self { + strongSelf.updateData(countryCode: Int32(code), number: strongSelf.controllerNode.codeAndNumber.1) + strongSelf.controllerNode.activateInput() + } + } + strongSelf.controllerNode.view.endEditing(true) + strongSelf.present(controller, in: .window) + } + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + self.controllerNode.activateInput() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.controllerNode.activateInput() + } + + override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: 0.0, transition: transition) + } + + @objc func nextPressed() { + let (_, number) = self.controllerNode.codeAndNumber + if !number.isEmpty { + self.inProgress = true + self.requestDisposable.set((requestChangeAccountPhoneNumberVerification(account: self.account, phoneNumber: self.controllerNode.currentNumber) |> deliverOnMainQueue).start(next: { [weak self] next in + if let strongSelf = self { + strongSelf.inProgress = false + (strongSelf.navigationController as? NavigationController)?.pushViewController(changePhoneNumberCodeController(account: strongSelf.account, phoneNumber: strongSelf.controllerNode.currentNumber, codeData: next)) + } + }, error: { [weak self] error in + if let strongSelf = self { + strongSelf.inProgress = false + + let text: String + switch error { + case .limitExceeded: + text = "You have requested authorization code too many times. Please try again later." + case .invalidPhoneNumber: + text = "The phone number you entered is not valid. Please enter the correct number along with your area code." + case .phoneNumberOccupied: + text = "The number \(number) is already connected to a Telegram account. Please delete that account before migrating to the new number." + case .generic: + text = "An error occurred. Please try again later." + } + + strongSelf.present(standardTextAlertController(title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: "OK", action: {})]), in: .window) + } + })) + } else { + hapticFeedback.error() + self.controllerNode.animateError() + } + } +} diff --git a/TelegramUI/ChangePhoneNumberControllerNode.swift b/TelegramUI/ChangePhoneNumberControllerNode.swift new file mode 100644 index 0000000000..23464d001c --- /dev/null +++ b/TelegramUI/ChangePhoneNumberControllerNode.swift @@ -0,0 +1,190 @@ +import Foundation +import AsyncDisplayKit +import Display +import TelegramCore + +private let countryButtonBackground = generateImage(CGSize(width: 45.0, height: 44.0 + 6.0), rotatedContext: { size, context in + let arrowSize: CGFloat = 6.0 + let lineWidth = UIScreenPixel + + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor.white.cgColor) + context.fill(CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height - arrowSize))) + context.move(to: CGPoint(x: size.width, y: size.height - arrowSize)) + context.addLine(to: CGPoint(x: size.width - 1.0, y: size.height - arrowSize)) + context.addLine(to: CGPoint(x: size.width - 1.0 - arrowSize, y: size.height)) + context.addLine(to: CGPoint(x: size.width - 1.0 - arrowSize - arrowSize, y: size.height - arrowSize)) + context.closePath() + context.fillPath() + + context.setStrokeColor(UIColor(0xbcbbc1).cgColor) + context.setLineWidth(lineWidth) + + context.move(to: CGPoint(x: size.width, y: size.height - arrowSize - lineWidth / 2.0)) + context.addLine(to: CGPoint(x: size.width - 1.0, y: size.height - arrowSize - lineWidth / 2.0)) + context.addLine(to: CGPoint(x: size.width - 1.0 - arrowSize, y: size.height - lineWidth / 2.0)) + context.addLine(to: CGPoint(x: size.width - 1.0 - arrowSize - arrowSize, y: size.height - arrowSize - lineWidth / 2.0)) + context.addLine(to: CGPoint(x: 15.0, y: size.height - arrowSize - lineWidth / 2.0)) + context.strokePath() + + context.move(to: CGPoint(x: 0.0, y: lineWidth / 2.0)) + context.addLine(to: CGPoint(x: size.width, y: lineWidth / 2.0)) + context.strokePath() +})?.stretchableImage(withLeftCapWidth: 46, topCapHeight: 1) + +private let countryButtonHighlightedBackground = generateImage(CGSize(width: 45.0, height: 44.0 + 6.0), rotatedContext: { size, context in + let arrowSize: CGFloat = 6.0 + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor(0xbcbbc1).cgColor) + context.fill(CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height - arrowSize))) + context.move(to: CGPoint(x: size.width, y: size.height - arrowSize)) + context.addLine(to: CGPoint(x: size.width - 1.0, y: size.height - arrowSize)) + context.addLine(to: CGPoint(x: size.width - 1.0 - arrowSize, y: size.height)) + context.addLine(to: CGPoint(x: size.width - 1.0 - arrowSize - arrowSize, y: size.height - arrowSize)) + context.closePath() + context.fillPath() +})?.stretchableImage(withLeftCapWidth: 46, topCapHeight: 2) + +private let phoneInputBackground = generateImage(CGSize(width: 60.0, height: 44.0), rotatedContext: { size, context in + let lineWidth = UIScreenPixel + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor.white.cgColor) + context.fill(CGRect(origin: CGPoint(), size: size)) + context.setStrokeColor(UIColor(0xbcbbc1).cgColor) + context.setLineWidth(lineWidth) + context.move(to: CGPoint(x: 0.0, y: size.height - lineWidth / 2.0)) + context.addLine(to: CGPoint(x: size.width, y: size.height - lineWidth / 2.0)) + context.strokePath() + context.move(to: CGPoint(x: size.width - 2.0 + lineWidth / 2.0, y: size.height - lineWidth / 2.0)) + context.addLine(to: CGPoint(x: size.width - 2.0 + lineWidth / 2.0, y: 0.0)) + context.strokePath() +})?.stretchableImage(withLeftCapWidth: 61, topCapHeight: 2) + +final class ChangePhoneNumberControllerNode: ASDisplayNode { + private let titleNode: ASTextNode + private let noticeNode: ASTextNode + private let countryButton: ASButtonNode + private let phoneBackground: ASImageNode + private let phoneInputNode: PhoneInputNode + + var currentNumber: String { + return self.phoneInputNode.number + } + + var codeAndNumber: (Int32?, String) { + get { + return self.phoneInputNode.codeAndNumber + } set(value) { + self.phoneInputNode.codeAndNumber = value + } + } + + var selectCountryCode: (() -> Void)? + + var inProgress: Bool = false { + didSet { + self.phoneInputNode.enableEditing = !self.inProgress + self.phoneInputNode.alpha = self.inProgress ? 0.6 : 1.0 + self.countryButton.isEnabled = !self.inProgress + } + } + + override init() { + self.titleNode = ASTextNode() + self.titleNode.isLayerBacked = true + self.titleNode.displaysAsynchronously = false + self.titleNode.attributedText = NSAttributedString(string: "NEW NUMBER", font: Font.regular(14.0), textColor: UIColor(0x6d6d72)) + + self.noticeNode = ASTextNode() + self.noticeNode.isLayerBacked = true + self.noticeNode.displaysAsynchronously = false + self.noticeNode.attributedText = NSAttributedString(string: "We will send an SMS with a confirmation code to your new number.", font: Font.regular(14.0), textColor: UIColor(0x6d6d72)) + + self.countryButton = ASButtonNode() + self.countryButton.setBackgroundImage(countryButtonBackground, for: []) + self.countryButton.setBackgroundImage(countryButtonHighlightedBackground, for: .highlighted) + + self.phoneBackground = ASImageNode() + self.phoneBackground.image = phoneInputBackground + self.phoneBackground.displaysAsynchronously = false + self.phoneBackground.displayWithoutProcessing = true + self.phoneBackground.isLayerBacked = true + + self.phoneInputNode = PhoneInputNode(fontSize: 17.0) + + super.init(viewBlock: { + return UITracingLayerView() + }, didLoad: nil) + + self.backgroundColor = UIColor(0xefefef) + + self.addSubnode(self.titleNode) + self.addSubnode(self.noticeNode) + self.addSubnode(self.phoneBackground) + self.addSubnode(self.countryButton) + self.addSubnode(self.phoneInputNode) + + self.countryButton.contentEdgeInsets = UIEdgeInsets(top: 0.0, left: 15.0, bottom: 4.0, right: 0.0) + self.countryButton.contentHorizontalAlignment = .left + + self.phoneInputNode.numberField.textField.attributedPlaceholder = NSAttributedString(string: "Your phone number", font: Font.regular(17.0), textColor: UIColor(0xbcbcc3)) + + self.countryButton.addTarget(self, action: #selector(self.countryPressed), forControlEvents: .touchUpInside) + + self.phoneInputNode.countryCodeUpdated = { [weak self] code in + if let strongSelf = self { + if let code = Int(code), let countryName = countryCodeToName[code] { + strongSelf.countryButton.setTitle(countryName, with: Font.regular(17.0), with: .black, for: []) + } else { + strongSelf.countryButton.setTitle("Select Country", with: Font.regular(17.0), with: .black, for: []) + } + } + } + + self.phoneInputNode.number = "+1" + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + let insets = layout.insets(options: [.statusBar, .input]) + + let countryButtonHeight: CGFloat = 44.0 + let inputFieldsHeight: CGFloat = 44.0 + + let titleSize = self.titleNode.measure(CGSize(width: layout.size.width - 28.0, height: CGFloat.greatestFiniteMagnitude)) + let noticeSize = self.noticeNode.measure(CGSize(width: layout.size.width - 28.0, height: CGFloat.greatestFiniteMagnitude)) + + let navigationHeight: CGFloat = 97.0 + insets.top + navigationBarHeight + + let inputHeight = countryButtonHeight + inputFieldsHeight + + transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: 15.0, y: navigationHeight - titleSize.height - 8.0), size: titleSize)) + + transition.updateFrame(node: self.countryButton, frame: CGRect(origin: CGPoint(x: 0.0, y: navigationHeight), size: CGSize(width: layout.size.width, height: 44.0 + 6.0))) + transition.updateFrame(node: self.phoneBackground, frame: CGRect(origin: CGPoint(x: 0.0, y: navigationHeight + 44.0), size: CGSize(width: layout.size.width, height: 44.0))) + + let countryCodeFrame = CGRect(origin: CGPoint(x: 9.0, y: navigationHeight + 44.0 + 1.0), size: CGSize(width: 45.0, height: 44.0)) + let numberFrame = CGRect(origin: CGPoint(x: 70.0, y: navigationHeight + 44.0 + 1.0), size: CGSize(width: layout.size.width - 70.0 - 8.0, height: 44.0)) + + let phoneInputFrame = countryCodeFrame.union(numberFrame) + + transition.updateFrame(node: self.phoneInputNode, frame: phoneInputFrame) + transition.updateFrame(node: self.phoneInputNode.countryCodeField, frame: countryCodeFrame.offsetBy(dx: -phoneInputFrame.minX, dy: -phoneInputFrame.minY)) + transition.updateFrame(node: self.phoneInputNode.numberField, frame: numberFrame.offsetBy(dx: -phoneInputFrame.minX, dy: -phoneInputFrame.minY)) + + transition.updateFrame(node: self.noticeNode, frame: CGRect(origin: CGPoint(x: 15.0, y: navigationHeight + inputHeight + 8.0), size: noticeSize)) + } + + func activateInput() { + self.phoneInputNode.numberField.textField.becomeFirstResponder() + } + + func animateError() { + self.phoneInputNode.countryCodeField.layer.addShakeAnimation() + self.phoneInputNode.numberField.layer.addShakeAnimation() + } + + @objc func countryPressed() { + self.selectCountryCode?() + } + +} diff --git a/TelegramUI/ChangePhoneNumberIntroController.swift b/TelegramUI/ChangePhoneNumberIntroController.swift new file mode 100644 index 0000000000..83873d35fa --- /dev/null +++ b/TelegramUI/ChangePhoneNumberIntroController.swift @@ -0,0 +1,126 @@ +import Foundation +import Display +import AsyncDisplayKit +import TelegramCore + +private final class ChangePhoneNumberIntroControllerNode: ASDisplayNode { + let iconNode: ASImageNode + let labelNode: ASTextNode + let buttonNode: HighlightableButtonNode + + var dismiss: (() -> Void)? + var action: (() -> Void)? + + override init() { + self.iconNode = ASImageNode() + self.labelNode = ASTextNode() + self.buttonNode = HighlightableButtonNode() + + super.init(viewBlock: { + return UITracingLayerView() + }, didLoad: nil) + + self.iconNode.image = UIImage(bundleImageName: "Settings/ChangePhoneIntroIcon")?.precomposed() + self.labelNode.attributedText = NSAttributedString(string: "You can change your Telegram number here. Your account and all your cloud data — messages, media, contacts, etc. will be moved to the new number.\n\nImportant: all your Telegram contacts will get your new number added to their address book, provided they had your old number and you haven't blocked them in Telegram.", font: Font.regular(14.0), textColor: UIColor(0x6d6d72), paragraphAlignment: .center) + self.buttonNode.setTitle("Change Number", with: Font.regular(19.0), with: UIColor(0x007ee5), for: .normal) + self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) + + self.addSubnode(self.iconNode) + self.addSubnode(self.labelNode) + self.addSubnode(self.buttonNode) + + self.backgroundColor = UIColor(0xefeff4) + } + + func animateIn() { + self.layer.animatePosition(from: CGPoint(x: self.layer.position.x, y: self.layer.position.y + self.layer.bounds.size.height), to: self.layer.position, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring) + } + + func animateOut() { + self.layer.animatePosition(from: self.layer.position, to: CGPoint(x: self.layer.position.x, y: self.layer.position.y + self.layer.bounds.size.height), duration: 0.2, timingFunction: kCAMediaTimingFunctionEaseInEaseOut, removeOnCompletion: false, completion: { [weak self] _ in + if let strongSelf = self { + strongSelf.dismiss?() + } + }) + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + var insets = layout.insets(options: [.statusBar]) + insets.top += navigationBarHeight + let availableHeight = layout.size.height - insets.top - insets.bottom + + let largeScreen = availableHeight >= 420.0 + let contentHeight: CGFloat = largeScreen ? 420.0 : 400.0 + + let iconSize = self.iconNode.measure(CGSize(width: 400.0, height: 400.0)) + let labelSize = self.labelNode.measure(CGSize(width: 295.0, height: CGFloat.greatestFiniteMagnitude)) + let buttonSize = self.buttonNode.measure(CGSize(width: 295.0, height: CGFloat.greatestFiniteMagnitude)) + + transition.updateFrame(node: self.iconNode, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - iconSize.width) / 2.0), y: insets.top + floor((availableHeight - contentHeight) / 2.0) + floor(iconSize.height * (largeScreen ? CGFloat(0.2) : CGFloat(0.5)))), size: iconSize)) + + transition.updateFrame(node: self.labelNode, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - labelSize.width) / 2.0), y: insets.top + floor((availableHeight - contentHeight) / 2.0) + floor((contentHeight - labelSize.height) / 2.0) + floor((contentHeight - iconSize.height - buttonSize.height) * 0.11)), size: labelSize)) + + transition.updateFrame(node: self.buttonNode, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - buttonSize.width) / 2.0), y: insets.top + floor((availableHeight - contentHeight) / 2.0) + contentHeight - buttonSize.height), size: buttonSize)) + } + + @objc func buttonPressed() { + self.action?() + } +} + +final class ChangePhoneNumberIntroController: ViewController { + private let account: Account + private var didPlayPresentationAnimation = false + + init(account: Account, phoneNumber: String) { + self.account = account + + super.init(navigationBar: NavigationBar()) + + self.title = phoneNumber + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + //self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Cancel", style: .plain, target: self, action: #selector(self.cancelPressed)) + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadDisplayNode() { + self.displayNode = ChangePhoneNumberIntroControllerNode() + (self.displayNode as! ChangePhoneNumberIntroControllerNode).dismiss = { [weak self] in + self?.presentingViewController?.dismiss(animated: false, completion: nil) + } + (self.displayNode as! ChangePhoneNumberIntroControllerNode).action = { [weak self] in + self?.proceed() + } + self.displayNodeDidLoad() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + /*if !self.didPlayPresentationAnimation { + self.didPlayPresentationAnimation = true + (self.displayNode as! ChangePhoneNumberIntroControllerNode).animateIn() + }*/ + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + (self.displayNode as! ChangePhoneNumberIntroControllerNode).containerLayoutUpdated(layout, navigationBarHeight: self.navigationHeight, transition: transition) + } + + @objc func cancelPressed() { + (self.displayNode as! ChangePhoneNumberIntroControllerNode).animateOut() + } + + func proceed() { + self.present(standardTextAlertController(title: nil, text: "All your Telegram contacts will get your new number added to their address book, provided they had your old number and you haven't blocked them in Telegram.", actions: [TextAlertAction(type: .defaultAction, title: "Cancel", action: {}), TextAlertAction(type: .genericAction, title: "OK", action: { [weak self] in + if let strongSelf = self { + (strongSelf.navigationController as? NavigationController)?.replaceTopController(ChangePhoneNumberController(account: strongSelf.account), animated: true) + } + })]), in: .window, with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + } +} diff --git a/TelegramUI/ChannelAdminsController.swift b/TelegramUI/ChannelAdminsController.swift index ba63b414b4..cde63ca7d1 100644 --- a/TelegramUI/ChannelAdminsController.swift +++ b/TelegramUI/ChannelAdminsController.swift @@ -200,7 +200,7 @@ private enum ChannelAdminsEntry: ItemListNodeEntry { arguments.updateCurrentAdministrationType() }) case let .administrationInfo(text): - return ItemListTextItem(text: text, sectionId: self.section) + return ItemListTextItem(text: .plain(text), sectionId: self.section) case let .adminsHeader(title): return ItemListSectionHeaderItem(text: title, sectionId: self.section) case let .adminPeerItem(_, participant, editing, enabled): @@ -221,7 +221,7 @@ private enum ChannelAdminsEntry: ItemListNodeEntry { arguments.addAdmin() }) case let .adminsInfo(text): - return ItemListTextItem(text: text, sectionId: self.section) + return ItemListTextItem(text: .plain(text), sectionId: self.section) } } } diff --git a/TelegramUI/ChannelInfoController.swift b/TelegramUI/ChannelInfoController.swift index 36688d0bab..e364407997 100644 --- a/TelegramUI/ChannelInfoController.swift +++ b/TelegramUI/ChannelInfoController.swift @@ -313,7 +313,7 @@ private func channelInfoEntries(account: Account, view: PeerView, state: Channel break } - let infoState = ItemListAvatarAndNameInfoItemState(editingName: state.editingState?.editingName, updatingName: nil) + let infoState = ItemListAvatarAndNameInfoItemState(editingName: canManageChannel ? state.editingState?.editingName : nil, updatingName: nil) entries.append(.info(peer: peer, cachedData: view.cachedData, state: infoState)) if let cachedChannelData = view.cachedData as? CachedChannelData { @@ -399,10 +399,9 @@ public func channelInfoController(account: Account, peerId: PeerId) -> ViewContr statePromise.set(stateValue.modify { f($0) }) } - - var pushControllerImpl: ((ViewController) -> Void)? var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments) -> Void)? + var popToRootControllerImpl: (() -> Void)? let actionsDisposable = DisposableSet() @@ -491,7 +490,21 @@ public func channelInfoController(account: Account, peerId: PeerId) -> ViewContr }, reportChannel: { }, leaveChannel: { - + let controller = ActionSheetController() + let dismissAction: () -> Void = { [weak controller] in + controller?.dismissAnimated() + } + controller.setItemGroups([ + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "Leave Channel", action: { + let _ = removePeerChat(postbox: account.postbox, peerId: peerId, reportChatSpam: false).start() + dismissAction() + popToRootControllerImpl?() + }), + ]), + ActionSheetItemGroup(items: [ActionSheetButtonItem(title: "Cancel", action: { dismissAction() })]) + ]) + presentControllerImpl?(controller, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) }, deleteChannel: { }) @@ -499,8 +512,21 @@ public func channelInfoController(account: Account, peerId: PeerId) -> ViewContr let signal = combineLatest(statePromise.get(), account.viewTracker.peerView(peerId)) |> map { state, view -> (ItemListControllerState, (ItemListNodeState, ChannelInfoEntry.ItemGenerationArguments)) in let peer = peerViewMainPeer(view) + + var canManageChannel = false + if let peer = peer as? TelegramChannel { + switch peer.role { + case .creator: + canManageChannel = true + case .moderator: + break + case .editor, .member: + break + } + } + var leftNavigationButton: ItemListNavigationButton? - let rightNavigationButton: ItemListNavigationButton + var rightNavigationButton: ItemListNavigationButton? if let editingState = state.editingState { leftNavigationButton = ItemListNavigationButton(title: "Cancel", style: .regular, enabled: true, action: { updateState { @@ -561,7 +587,7 @@ public func channelInfoController(account: Account, peerId: PeerId) -> ViewContr })) }) } - } else { + } else if canManageChannel { rightNavigationButton = ItemListNavigationButton(title: "Edit", style: .regular, enabled: true, action: { if let channel = peer as? TelegramChannel, case .broadcast = channel.info { var text = "" @@ -591,5 +617,8 @@ public func channelInfoController(account: Account, peerId: PeerId) -> ViewContr presentControllerImpl = { [weak controller] value, presentationArguments in controller?.present(value, in: .window, with: presentationArguments) } + popToRootControllerImpl = { [weak controller] in + (controller?.navigationController as? NavigationController)?.popToRoot(animated: true) + } return controller } diff --git a/TelegramUI/ChannelMembersController.swift b/TelegramUI/ChannelMembersController.swift index 188292e82e..55e04e1f92 100644 --- a/TelegramUI/ChannelMembersController.swift +++ b/TelegramUI/ChannelMembersController.swift @@ -133,7 +133,7 @@ private enum ChannelMembersEntry: ItemListNodeEntry { arguments.addMember() }) case .addMemberInfo: - return ItemListTextItem(text: "Only channel admins can see this list.", sectionId: self.section) + return ItemListTextItem(text: .plain("Only channel admins can see this list."), sectionId: self.section) case let .peerItem(_, participant, editing, enabled): return ItemListPeerItem(account: arguments.account, peer: participant.peer, presence: nil, text: .none, label: nil, editing: editing, switchValue: nil, enabled: enabled, sectionId: self.section, action: nil, setPeerIdWithRevealedOptions: { previousId, id in arguments.setPeerIdWithRevealedOptions(previousId, id) diff --git a/TelegramUI/ChannelVisibilityController.swift b/TelegramUI/ChannelVisibilityController.swift index 3fe4fc8eee..bb01202987 100644 --- a/TelegramUI/ChannelVisibilityController.swift +++ b/TelegramUI/ChannelVisibilityController.swift @@ -196,7 +196,7 @@ private enum ChannelVisibilityEntry: ItemListNodeEntry { arguments.updateCurrentType(.privateChannel) }) case let .typeInfo(text): - return ItemListTextItem(text: text, sectionId: self.section) + return ItemListTextItem(text: .plain(text), sectionId: self.section) case let .publicLinkAvailability(value): if value { return ItemListActivityTextItem(displayActivity: true, text: NSAttributedString(string: "Checking", textColor: UIColor(0x6d6d72)), sectionId: self.section) @@ -216,9 +216,9 @@ private enum ChannelVisibilityEntry: ItemListNodeEntry { }) case let .privateLinkInfo(text): - return ItemListTextItem(text: text, sectionId: self.section) + return ItemListTextItem(text: .plain(text), sectionId: self.section) case let .publicLinkInfo(text): - return ItemListTextItem(text: text, sectionId: self.section) + return ItemListTextItem(text: .plain(text), sectionId: self.section) case let .publicLinkStatus(addressName, status): var displayActivity = false let text: NSAttributedString @@ -251,7 +251,7 @@ private enum ChannelVisibilityEntry: ItemListNodeEntry { } return ItemListActivityTextItem(displayActivity: displayActivity, text: text, sectionId: self.section) case let .existingLinksInfo(text): - return ItemListTextItem(text: text, sectionId: self.section) + return ItemListTextItem(text: .plain(text), sectionId: self.section) case let .existingLinkPeerItem(_, peer, editing, enabled): var label = "" if let addressName = peer.addressName { diff --git a/TelegramUI/ChatBotInfoItem.swift b/TelegramUI/ChatBotInfoItem.swift index be2c0a5f94..d76a6f0094 100644 --- a/TelegramUI/ChatBotInfoItem.swift +++ b/TelegramUI/ChatBotInfoItem.swift @@ -186,7 +186,7 @@ final class ChatBotInfoItemNode: ListViewItemNode { case let .peerMention(peerId): foundTapAction = true if let controllerInteraction = self.controllerInteraction { - controllerInteraction.openPeer(peerId, .chat(textInputState: nil)) + controllerInteraction.openPeer(peerId, .chat(textInputState: nil), nil) } case let .textMention(name): foundTapAction = true diff --git a/TelegramUI/ChatButtonKeyboardInputNode.swift b/TelegramUI/ChatButtonKeyboardInputNode.swift index 904b4edff6..6a9ce825e4 100644 --- a/TelegramUI/ChatButtonKeyboardInputNode.swift +++ b/TelegramUI/ChatButtonKeyboardInputNode.swift @@ -180,7 +180,7 @@ final class ChatButtonKeyboardInputNode: ChatInputNode { peerId = message.id.peerId } if let botPeer = botPeer, let addressName = botPeer.addressName { - controllerInteraction.openPeer(peerId, .chat(textInputState: ChatTextInputState(inputText: "@\(addressName) \(query)"))) + controllerInteraction.openPeer(peerId, .chat(textInputState: ChatTextInputState(inputText: "@\(addressName) \(query)")), nil) } } } diff --git a/TelegramUI/ChatChannelSubscriberInputPanelNode.swift b/TelegramUI/ChatChannelSubscriberInputPanelNode.swift index 76adf0c4fd..02b096b7c8 100644 --- a/TelegramUI/ChatChannelSubscriberInputPanelNode.swift +++ b/TelegramUI/ChatChannelSubscriberInputPanelNode.swift @@ -25,7 +25,7 @@ private func titleAndColorForAction(_ action: SubscriberAction) -> (String, UICo } } -private func actionForPeer(_ peer: Peer) -> SubscriberAction? { +private func actionForPeer(peer: Peer, muteState: PeerMuteState) -> SubscriberAction? { if let channel = peer as? TelegramChannel { switch channel.participationStatus { case .kicked: @@ -33,9 +33,12 @@ private func actionForPeer(_ peer: Peer) -> SubscriberAction? { case .left: return .join case .member: - return .muteNotifications + if case .unmuted = muteState { + return .muteNotifications + } else { + return .unmuteNotifications + } } - return .join } else { return nil } @@ -45,12 +48,17 @@ final class ChatChannelSubscriberInputPanelNode: ChatInputPanelNode { private let button: UIButton private let activityIndicator: UIActivityIndicatorView + private var muteState: PeerMuteState = .unmuted private var action: SubscriberAction? private let actionDisposable = MetaDisposable() private var presentationInterfaceState = ChatPresentationInterfaceState() + private var notificationSettingsDisposable = MetaDisposable() + + private var layoutData: CGFloat? + override init() { self.button = UIButton() self.activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray) @@ -66,6 +74,7 @@ final class ChatChannelSubscriberInputPanelNode: ChatInputPanelNode { deinit { self.actionDisposable.dispose() + self.notificationSettingsDisposable.dispose() } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { @@ -97,19 +106,27 @@ final class ChatChannelSubscriberInputPanelNode: ChatInputPanelNode { case .kicked: break case .muteNotifications: - break + if let account = self.account, let peer = self.presentationInterfaceState.peer { + let muteState: PeerMuteState = .muted(until: Int32.max) + self.actionDisposable.set(changePeerNotificationSettings(account: account, peerId: peer.id, settings: TelegramPeerNotificationSettings(muteState: muteState, messageSound: PeerMessageSound.bundledModern(id: 0))).start()) + } case .unmuteNotifications: - break + if let account = self.account, let peer = self.presentationInterfaceState.peer { + let muteState: PeerMuteState = .unmuted + self.actionDisposable.set(changePeerNotificationSettings(account: account, peerId: peer.id, settings: TelegramPeerNotificationSettings(muteState: muteState, messageSound: PeerMessageSound.bundledModern(id: 0))).start()) + } } } override func updateLayout(width: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> CGFloat { + self.layoutData = width + if self.presentationInterfaceState != interfaceState { let previousState = self.presentationInterfaceState self.presentationInterfaceState = interfaceState if let peer = interfaceState.peer, previousState.peer == nil || !peer.isEqual(previousState.peer!) { - if let action = actionForPeer(peer) { + if let action = actionForPeer(peer: peer, muteState: self.muteState) { self.action = action let (title, color) = titleAndColorForAction(action) self.button.setTitle(title, for: []) @@ -119,6 +136,34 @@ final class ChatChannelSubscriberInputPanelNode: ChatInputPanelNode { } else { self.action = nil } + + if let account = self.account { + self.notificationSettingsDisposable.set((account.postbox.peerView(id: peer.id) |> map { view -> PeerMuteState in + if let notificationSettings = view.notificationSettings as? TelegramPeerNotificationSettings { + return notificationSettings.muteState + } else { + return .unmuted + } + } + |> distinctUntilChanged |> deliverOnMainQueue).start(next: { [weak self] muteState in + if let strongSelf = self, let peer = strongSelf.presentationInterfaceState.peer { + strongSelf.muteState = muteState + let action = actionForPeer(peer: peer, muteState: muteState) + if let layoutData = strongSelf.layoutData, action != strongSelf.action { + strongSelf.action = action + if let action = action { + let (title, color) = titleAndColorForAction(action) + strongSelf.button.setTitle(title, for: []) + strongSelf.button.setTitleColor(color, for: [.normal]) + strongSelf.button.setTitleColor(color.withAlphaComponent(0.5), for: [.highlighted]) + strongSelf.button.sizeToFit() + } + + let _ = strongSelf.updateLayout(width: layoutData, transition: .immediate, interfaceState: strongSelf.presentationInterfaceState) + } + } + })) + } } } diff --git a/TelegramUI/ChatController.swift b/TelegramUI/ChatController.swift index 042e7adaf9..fd0a5f9cdc 100644 --- a/TelegramUI/ChatController.swift +++ b/TelegramUI/ChatController.swift @@ -76,6 +76,8 @@ public class ChatController: TelegramController { private let typingActivityPromise = Promise() private var typingActivityDisposable: Disposable? + private var historyNavigationStack = ChatHistoryNavigationStack() + public init(account: Account, peerId: PeerId, messageId: MessageId? = nil, botStart: ChatControllerInitialBotStart? = nil) { self.account = account self.peerId = peerId @@ -118,7 +120,16 @@ public class ChatController: TelegramController { } if let galleryMedia = galleryMedia { - if let file = galleryMedia as? TelegramMediaFile, file.isMusic || file.isVoice { + if let file = galleryMedia as? TelegramMediaFile, file.isSticker { + for attribute in file.attributes { + if case let .Sticker(_, reference) = attribute { + if let reference = reference { + strongSelf.present(StickerPackPreviewController(account: strongSelf.account, stickerPack: reference), in: .window) + } + break + } + } + } else if let file = galleryMedia as? TelegramMediaFile, file.isMusic || file.isVoice { if let applicationContext = strongSelf.account.applicationContext as? TelegramApplicationContext { let player = ManagedAudioPlaylistPlayer(postbox: strongSelf.account.postbox, playlist: peerMessageHistoryAudioPlaylist(account: strongSelf.account, messageId: id)) applicationContext.mediaManager.setPlaylistPlayer(player) @@ -185,9 +196,9 @@ public class ChatController: TelegramController { strongSelf.secretMediaPreviewController?.dismiss() strongSelf.secretMediaPreviewController = nil } - }, openPeer: { [weak self] id, navigation in + }, openPeer: { [weak self] id, navigation, fromMessageId in if let strongSelf = self { - strongSelf.openPeer(id, navigation) + strongSelf.openPeer(peerId: id, navigation: navigation, fromMessageId: fromMessageId) } }, openPeerMention: { [weak self] name in if let strongSelf = self { @@ -735,6 +746,12 @@ public class ChatController: TelegramController { } } + self.chatDisplayNode.historyNode.maxVisibleMessageIndexUpdated = { [weak self] index in + if let strongSelf = self, !strongSelf.historyNavigationStack.isEmpty { + strongSelf.historyNavigationStack.filterOutIndicesLessThan(index) + } + } + self.chatDisplayNode.requestLayout = { [weak self] transition in self?.requestLayout(transition: transition) } @@ -902,7 +919,11 @@ public class ChatController: TelegramController { self.chatDisplayNode.navigateToLatestButton.tapped = { [weak self] in if let strongSelf = self, strongSelf.isNodeLoaded { - strongSelf.chatDisplayNode.historyNode.scrollToEndOfHistory() + if let messageId = strongSelf.historyNavigationStack.removeLast() { + strongSelf.navigateToMessage(from: nil, to: messageId.id, rememberInStack: false) + } else { + strongSelf.chatDisplayNode.historyNode.scrollToEndOfHistory() + } } } @@ -1087,7 +1108,7 @@ public class ChatController: TelegramController { } }, botSwitchChatWithPayload: { [weak self] peerId, payload in if let strongSelf = self { - strongSelf.openPeer(peerId, .withBotStartPayload(ChatControllerInitialBotStart(payload: payload, behavior: .automatic(returnToPeerId: strongSelf.peerId)))) + strongSelf.openPeer(peerId: peerId, navigation: .withBotStartPayload(ChatControllerInitialBotStart(payload: payload, behavior: .automatic(returnToPeerId: strongSelf.peerId))), fromMessageId: nil) } }, beginAudioRecording: { [weak self] in self?.requestAudioRecorder() @@ -1185,7 +1206,7 @@ public class ChatController: TelegramController { }, dismissReportPeer: { [weak self] in self?.dismissReportPeer() }, deleteChat: { [weak self] in - self?.deleteChat() + self?.deleteChat(reportChatSpam: false) }, statuses: ChatPanelInterfaceInteractionStatuses(editingMessage: self.editingMessage.get(), startingBot: self.startingBot.get(), unblockingPeer: self.unblockingPeer.get())) self.chatUnreadCountDisposable = (self.account.postbox.unreadMessageCountsView(items: [.peer(self.peerId)]) |> deliverOnMainQueue).start(next: { [weak self] items in @@ -1615,7 +1636,7 @@ public class ChatController: TelegramController { self.audioRecorder.set(.single(nil)) } - private func navigateToMessage(from fromId: MessageId?, to toId: MessageId) { + private func navigateToMessage(from fromId: MessageId?, to toId: MessageId, rememberInStack: Bool = true) { if self.isNodeLoaded { if toId.peerId == self.peerId { var fromIndex: MessageIndex? @@ -1629,6 +1650,10 @@ public class ChatController: TelegramController { } if let fromIndex = fromIndex { + if let _ = fromId, rememberInStack { + self.historyNavigationStack.add(fromIndex) + } + if let message = self.chatDisplayNode.historyNode.messageInCurrentHistoryView(toId) { self.chatDisplayNode.historyNode.scrollToMessage(from: fromIndex, to: MessageIndex(message)) } else { @@ -1645,7 +1670,7 @@ public class ChatController: TelegramController { } } - private func openPeer(_ peerId: PeerId?, _ navigation: ChatControllerInteractionNavigateToPeer) { + private func openPeer(peerId: PeerId?, navigation: ChatControllerInteractionNavigateToPeer, fromMessageId: MessageId?) { if peerId == self.peerId { switch navigation { case .info: @@ -1669,7 +1694,19 @@ public class ChatController: TelegramController { if let peerId = peerId { switch navigation { case .info: - break + let peerSignal: Signal + if let fromMessageId = fromMessageId { + peerSignal = loadedPeerFromMessage(account: self.account, peerId: peerId, messageId: fromMessageId) + } else { + peerSignal = self.account.postbox.loadedPeerWithId(peerId) |> map { Optional($0) } + } + self.navigationActionDisposable.set((peerSignal |> take(1) |> deliverOnMainQueue).start(next: { [weak self] peer in + if let strongSelf = self, let peer = peer { + if let infoController = peerInfoController(account: strongSelf.account, peer: peer) { + (strongSelf.navigationController as? NavigationController)?.pushViewController(infoController) + } + } + })) case let .chat(textInputState): if let textInputState = textInputState { let _ = (self.account.postbox.modify({ modifier -> Void in @@ -1756,10 +1793,34 @@ public class ChatController: TelegramController { } private func reportPeer() { - self.editMessageDisposable.set((TelegramCore.reportPeer(account: self.account, peerId: self.peerId) |> afterDisposed({ - Queue.mainQueue().async { + if let peer = self.presentationInterfaceState.peer { + let title: String + if let _ = peer as? TelegramGroup { + title = "Report spam and leave group" + } else if let peer = peer as? TelegramChannel { + if case .group = peer.info { + title = "Report spam and leave group" + } else { + title = "Report spam and leave channel" + } + } else { + title = "Report spam and delete chat" } - })).start()) + let actionSheet = ActionSheetController() + actionSheet.setItemGroups([ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: title, color: .destructive, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + if let strongSelf = self { + strongSelf.deleteChat(reportChatSpam: true) + } + }) + ]), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "Cancel", color: .accent, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ])]) + self.present(actionSheet, in: .window) + } } private func dismissReportPeer() { @@ -1769,9 +1830,9 @@ public class ChatController: TelegramController { })).start()) } - private func deleteChat() { + private func deleteChat(reportChatSpam: Bool) { self.chatDisplayNode.historyNode.disconnect() - let _ = removePeerChat(postbox: self.account.postbox, peerId: self.peerId).start() + let _ = removePeerChat(postbox: self.account.postbox, peerId: self.peerId, reportChatSpam: reportChatSpam).start() (self.navigationController as? NavigationController)?.popToRoot(animated: true) } @@ -1803,9 +1864,9 @@ public class ChatController: TelegramController { applicationContext.openUrl(url) } case let .peer(peerId): - strongSelf.openPeer(peerId, .chat(textInputState: nil)) + strongSelf.openPeer(peerId: peerId, navigation: .chat(textInputState: nil), fromMessageId: nil) case let .botStart(peerId, payload): - strongSelf.openPeer(peerId, .withBotStartPayload(ChatControllerInitialBotStart(payload: payload, behavior: .interactive))) + strongSelf.openPeer(peerId: peerId, navigation: .withBotStartPayload(ChatControllerInitialBotStart(payload: payload, behavior: .interactive)), fromMessageId: nil) case let .groupBotStart(peerId, payload): break case let .channelMessage(peerId, messageId): diff --git a/TelegramUI/ChatControllerInteraction.swift b/TelegramUI/ChatControllerInteraction.swift index 0c1901a84d..59738f7018 100644 --- a/TelegramUI/ChatControllerInteraction.swift +++ b/TelegramUI/ChatControllerInteraction.swift @@ -27,7 +27,7 @@ public final class ChatControllerInteraction { let openMessage: (MessageId) -> Void let openSecretMessagePreview: (MessageId) -> Void let closeSecretMessagePreview: () -> Void - let openPeer: (PeerId?, ChatControllerInteractionNavigateToPeer) -> Void + let openPeer: (PeerId?, ChatControllerInteractionNavigateToPeer, MessageId?) -> Void let openPeerMention: (String) -> Void let openMessageContextMenu: (MessageId, ASDisplayNode, CGRect) -> Void let navigateToMessage: (MessageId, MessageId) -> Void @@ -47,7 +47,7 @@ public final class ChatControllerInteraction { let openHashtag: (String?, String) -> Void let updateInputState: ((ChatTextInputState) -> ChatTextInputState) -> Void - public init(openMessage: @escaping (MessageId) -> Void, openSecretMessagePreview: @escaping (MessageId) -> Void, closeSecretMessagePreview: @escaping () -> 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?, Bool) -> Void, openUrl: @escaping (String) -> Void, shareCurrentLocation: @escaping () -> Void, shareAccountContact: @escaping () -> Void, sendBotCommand: @escaping (MessageId?, String) -> Void, openInstantPage: @escaping (MessageId) -> Void, openHashtag: @escaping (String?, String) -> Void, updateInputState: @escaping ((ChatTextInputState) -> ChatTextInputState) -> Void) { + public init(openMessage: @escaping (MessageId) -> Void, openSecretMessagePreview: @escaping (MessageId) -> Void, closeSecretMessagePreview: @escaping () -> Void, openPeer: @escaping (PeerId?, ChatControllerInteractionNavigateToPeer, MessageId?) -> 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?, Bool) -> Void, openUrl: @escaping (String) -> Void, shareCurrentLocation: @escaping () -> Void, shareAccountContact: @escaping () -> Void, sendBotCommand: @escaping (MessageId?, String) -> Void, openInstantPage: @escaping (MessageId) -> Void, openHashtag: @escaping (String?, String) -> Void, updateInputState: @escaping ((ChatTextInputState) -> ChatTextInputState) -> Void) { self.openMessage = openMessage self.openSecretMessagePreview = openSecretMessagePreview self.closeSecretMessagePreview = closeSecretMessagePreview diff --git a/TelegramUI/ChatControllerNode.swift b/TelegramUI/ChatControllerNode.swift index 7dd94e4156..dadbb1a479 100644 --- a/TelegramUI/ChatControllerNode.swift +++ b/TelegramUI/ChatControllerNode.swift @@ -144,10 +144,12 @@ class ChatControllerNode: ASDisplayNode { if !entities.isEmpty { attributes.append(TextEntitiesMessageAttribute(entities: entities)) } + var webpage: TelegramMediaWebpage? if strongSelf.chatPresentationInterfaceState.interfaceState.composeDisableUrlPreview != nil { attributes.append(OutgoingContentInfoMessageAttribute(flags: [.disableLinkPreviews])) + } else { + webpage = strongSelf.chatPresentationInterfaceState.urlPreview?.1 } - let webpage = strongSelf.chatPresentationInterfaceState.urlPreview?.1 messages.append(.message(text: text, attributes: attributes, media: webpage, replyToMessageId: strongSelf.chatPresentationInterfaceState.interfaceState.replyMessageId)) } if let forwardMessageIds = strongSelf.chatPresentationInterfaceState.interfaceState.forwardMessageIds { @@ -510,7 +512,7 @@ class ChatControllerNode: ASDisplayNode { } if let dismissedInputNode = dismissedInputNode { - transition.updateFrame(node: dismissedInputNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - insets.bottom - CGFloat(FLT_EPSILON)), size: CGSize(width: layout.size.width, height: max(insets.bottom, dismissedInputNode.bounds.size.height))), completion: { [weak self, weak dismissedInputNode] completed in + transition.updateFrame(node: dismissedInputNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - insets.bottom - CGFloat.ulpOfOne), size: CGSize(width: layout.size.width, height: max(insets.bottom, dismissedInputNode.bounds.size.height))), completion: { [weak self, weak dismissedInputNode] completed in if completed { if let strongSelf = self { if strongSelf.inputNode !== dismissedInputNode { diff --git a/TelegramUI/ChatHistoryListNode.swift b/TelegramUI/ChatHistoryListNode.swift index 11e666a393..4bff04ef17 100644 --- a/TelegramUI/ChatHistoryListNode.swift +++ b/TelegramUI/ChatHistoryListNode.swift @@ -83,13 +83,19 @@ struct ChatHistoryListViewTransition { let cachedData: CachedPeerData? } -private func maxIncomingMessageIndexForEntries(_ entries: [ChatHistoryEntry], indexRange: (Int, Int)) -> MessageIndex? { +private func maxMessageIndexForEntries(_ entries: [ChatHistoryEntry], indexRange: (Int, Int)) -> (incoming: MessageIndex?, overall: MessageIndex?) { + var overall: MessageIndex? for i in (indexRange.0 ... indexRange.1).reversed() { - if case let .MessageEntry(message, _) = entries[i], message.flags.contains(.Incoming) { - return MessageIndex(message) + if case let .MessageEntry(message, _) = entries[i] { + if overall == nil { + overall = MessageIndex(message) + } + if message.flags.contains(.Incoming) { + return (MessageIndex(message), overall) + } } } - return nil + return (nil, overall) } private func mappedInsertEntries(account: Account, peerId: PeerId, controllerInteraction: ChatControllerInteraction, mode: ChatHistoryListMode, entries: [ChatHistoryViewTransitionInsertEntry]) -> [ListViewInsertItem] { @@ -212,6 +218,9 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { private let messageProcessingManager = ChatMessageThrottledProcessingManager() + private var maxVisibleMessageIndexReported: MessageIndex? + var maxVisibleMessageIndexUpdated: ((MessageIndex) -> Void)? + public init(account: Account, peerId: PeerId, tagMask: MessageTags?, messageId: MessageId?, controllerInteraction: ChatControllerInteraction, mode: ChatHistoryListMode = .bubbles) { self.account = account self.peerId = peerId @@ -226,16 +235,17 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { //self.debugInfo = true self.messageProcessingManager.process = { [weak account] messageIds in - account?.viewTracker.updatedViewCountMessageIds(messageIds: messageIds) + account?.viewTracker.updateViewCountForMessageIds(messageIds: messageIds) } self.preloadPages = false switch self.mode { case .bubbles: - self.transform = CATransform3DMakeRotation(CGFloat(M_PI), 0.0, 0.0, 1.0) + self.transform = CATransform3DMakeRotation(CGFloat(Double.pi), 0.0, 0.0, 1.0) case .list: break } + //self.snapToBottomInsetUntilFirstInteraction = true let messageViewQueue = self.messageViewQueue @@ -370,8 +380,15 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { strongSelf.messageProcessingManager.add(messageIdsWithViewCount) } - if let messageIndex = maxIncomingMessageIndexForEntries(historyView.filteredEntries, indexRange: indexRange) { - strongSelf.updateMaxVisibleReadIncomingMessageIndex(messageIndex) + let (maxIncomingIndex, maxOverallIndex) = maxMessageIndexForEntries(historyView.filteredEntries, indexRange: indexRange) + + if let maxIncomingIndex = maxIncomingIndex { + strongSelf.updateMaxVisibleReadIncomingMessageIndex(maxIncomingIndex) + } + + if let maxOverallIndex = maxOverallIndex, maxOverallIndex != strongSelf.maxVisibleMessageIndexReported { + strongSelf.maxVisibleMessageIndexReported = maxOverallIndex + strongSelf.maxVisibleMessageIndexUpdated?(maxOverallIndex) } } @@ -483,7 +500,8 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { strongSelf.account.postbox.updateMessageHistoryViewVisibleRange(transition.historyView.originalView.id, earliestVisibleIndex: transition.historyView.filteredEntries[transition.historyView.filteredEntries.count - 1 - range.lastIndex].index, latestVisibleIndex: transition.historyView.filteredEntries[transition.historyView.filteredEntries.count - 1 - range.firstIndex].index) if let visible = visibleRange.visibleRange { - if let messageIndex = maxIncomingMessageIndexForEntries(transition.historyView.filteredEntries, indexRange: (transition.historyView.filteredEntries.count - 1 - visible.lastIndex, transition.historyView.filteredEntries.count - 1 - visible.firstIndex)) { + let (messageIndex, _) = maxMessageIndexForEntries(transition.historyView.filteredEntries, indexRange: (transition.historyView.filteredEntries.count - 1 - visible.lastIndex, transition.historyView.filteredEntries.count - 1 - visible.firstIndex)) + if let messageIndex = messageIndex { strongSelf.updateMaxVisibleReadIncomingMessageIndex(messageIndex) } } diff --git a/TelegramUI/ChatHistoryNavigationStack.swift b/TelegramUI/ChatHistoryNavigationStack.swift new file mode 100644 index 0000000000..ac91a3fee5 --- /dev/null +++ b/TelegramUI/ChatHistoryNavigationStack.swift @@ -0,0 +1,29 @@ +import Foundation +import Postbox + +struct ChatHistoryNavigationStack { + private var messageIndices: [MessageIndex] = [] + + mutating func add(_ index: MessageIndex) { + self.messageIndices.append(index) + } + + mutating func removeLast() -> MessageIndex? { + if messageIndices.isEmpty { + return nil + } + return messageIndices.removeLast() + } + + var isEmpty: Bool { + return self.messageIndices.isEmpty + } + + mutating func filterOutIndicesLessThan(_ index: MessageIndex) { + for i in (0 ..< self.messageIndices.count).reversed() { + if self.messageIndices[i] <= index { + self.messageIndices.remove(at: i) + } + } + } +} diff --git a/TelegramUI/ChatInterfaceStateAccessoryPanels.swift b/TelegramUI/ChatInterfaceStateAccessoryPanels.swift index 6de2609e7a..d5d442355c 100644 --- a/TelegramUI/ChatInterfaceStateAccessoryPanels.swift +++ b/TelegramUI/ChatInterfaceStateAccessoryPanels.swift @@ -37,6 +37,7 @@ func accessoryPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceS } else if let urlPreview = chatPresentationInterfaceState.urlPreview, chatPresentationInterfaceState.interfaceState.composeDisableUrlPreview != urlPreview.0 { if let previewPanelNode = currentPanel as? WebpagePreviewAccessoryPanelNode, previewPanelNode.webpage.id == urlPreview.1.id { previewPanelNode.interfaceInteraction = interfaceInteraction + previewPanelNode.replaceWebpage(urlPreview.1) return previewPanelNode } else { let panelNode = WebpagePreviewAccessoryPanelNode(account: account, webpage: urlPreview.1) diff --git a/TelegramUI/ChatInterfaceStateInputPanels.swift b/TelegramUI/ChatInterfaceStateInputPanels.swift index 4c2f8d3b77..48e6cc66cf 100644 --- a/TelegramUI/ChatInterfaceStateInputPanels.swift +++ b/TelegramUI/ChatInterfaceStateInputPanels.swift @@ -54,7 +54,7 @@ func inputPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState } } else if let channel = peer as? TelegramChannel { switch channel.participationStatus { - case .kicked, .left: + case .kicked: if let currentPanel = currentPanel as? DeleteChatInputPanelNode { return currentPanel } else { @@ -63,7 +63,7 @@ func inputPanelForChatPresentationIntefaceState(_ chatPresentationInterfaceState panel.interfaceInteraction = interfaceInteraction return panel } - case .member: + case .member, .left: break } switch channel.info { diff --git a/TelegramUI/ChatListController.swift b/TelegramUI/ChatListController.swift index 32836cb7fd..8095f25d5f 100644 --- a/TelegramUI/ChatListController.swift +++ b/TelegramUI/ChatListController.swift @@ -114,7 +114,7 @@ public class ChatListController: TelegramController { actionSheet?.dismissAnimated() if let strongSelf = self { - let _ = removePeerChat(postbox: strongSelf.account.postbox, peerId: peerId).start() + let _ = removePeerChat(postbox: strongSelf.account.postbox, peerId: peerId, reportChatSpam: false).start() } }) ]), ActionSheetItemGroup(items: [ diff --git a/TelegramUI/ChatListItem.swift b/TelegramUI/ChatListItem.swift index ac25854f7b..6879da0dad 100644 --- a/TelegramUI/ChatListItem.swift +++ b/TelegramUI/ChatListItem.swift @@ -450,7 +450,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { if let embeddedState = embeddedState as? ChatEmbeddedInterfaceState { let mutableAttributedText = NSMutableAttributedString() mutableAttributedText.append(NSAttributedString(string: "Draft: ", font: textFont, textColor: UIColor(0xdd4b39))) - mutableAttributedText.append(NSAttributedString(string: embeddedState.text, font: textFont, textColor: UIColor.black)) + mutableAttributedText.append(NSAttributedString(string: embeddedState.text, font: textFont, textColor: UIColor(0x8e8e93))) attributedText = mutableAttributedText; } else if let message = message, let author = message.author as? TelegramUser, let peer = peer, !(peer is TelegramUser) { if let peer = peer as? TelegramChannel, case .broadcast = peer.info { diff --git a/TelegramUI/ChatMediaInputNode.swift b/TelegramUI/ChatMediaInputNode.swift index 580f3d3110..2aa4a5b765 100644 --- a/TelegramUI/ChatMediaInputNode.swift +++ b/TelegramUI/ChatMediaInputNode.swift @@ -107,7 +107,7 @@ private func chatMediaInputGridEntries(view: ItemCollectionsView, recentStickers } if let recentStickers = recentStickers, !recentStickers.items.isEmpty { - let packInfo = StickerPackCollectionInfo(id: ItemCollectionId(namespace: Namespaces.ItemCollection.CloudRecentStickers, id: 0), flags: [], accessHash: 0, title: "FREQUENTLY USED", shortName: "", hash: 0) + let packInfo = StickerPackCollectionInfo(id: ItemCollectionId(namespace: Namespaces.ItemCollection.CloudRecentStickers, id: 0), flags: [], accessHash: 0, title: "FREQUENTLY USED", shortName: "", hash: 0, count: 0) for i in 0 ..< min(20, recentStickers.items.count) { if let item = recentStickers.items[i].contents as? RecentMediaItem, let file = item.media as? TelegramMediaFile, let mediaId = item.media.id { let index = ItemCollectionItemIndex(index: Int32(i), id: mediaId.id) @@ -211,7 +211,7 @@ final class ChatMediaInputNode: ChatInputNode { self.collectionListSeparator.backgroundColor = UIColor(0xBEC2C6) self.listView = ListView() - self.listView.transform = CATransform3DMakeRotation(-CGFloat(M_PI / 2.0), 0.0, 0.0, 1.0) + self.listView.transform = CATransform3DMakeRotation(-CGFloat(Double.pi / 2.0), 0.0, 0.0, 1.0) self.gridNode = GridNode() diff --git a/TelegramUI/ChatMessageActionButtonsNode.swift b/TelegramUI/ChatMessageActionButtonsNode.swift index 6bc0b352e6..203f64f6a6 100644 --- a/TelegramUI/ChatMessageActionButtonsNode.swift +++ b/TelegramUI/ChatMessageActionButtonsNode.swift @@ -60,8 +60,9 @@ private final class ChatMessageActionButtonNode: ASDisplayNode { let titleLayout = TextNode.asyncLayout(maybeNode?.titleNode) return { button, constrainedWidth, position in - let sideInset: CGFloat = 5.0 - let (titleSize, titleApply) = titleLayout(NSAttributedString(string: button.title, font: titleFont, textColor: .white), nil, 1, .end, CGSize(width: max(1.0, constrainedWidth - sideInset - sideInset), height: CGFloat.greatestFiniteMagnitude), nil) + let sideInset: CGFloat = 8.0 + let minimumSideInset: CGFloat = 4.0 + let (titleSize, titleApply) = titleLayout(NSAttributedString(string: button.title, font: titleFont, textColor: .white), nil, 1, .end, CGSize(width: max(1.0, constrainedWidth - minimumSideInset - minimumSideInset), height: CGFloat.greatestFiniteMagnitude), nil) let backgroundImage: UIImage switch position { @@ -122,21 +123,23 @@ final class ChatMessageActionButtonsNode: ASDisplayNode { } } - class func asyncLayout(_ maybeNode: ChatMessageActionButtonsNode?) -> (_ replyMarkup: ReplyMarkupMessageAttribute, _ constrainedWidth: CGFloat) -> (CGSize, (_ animated: Bool) -> ChatMessageActionButtonsNode) { + //(_ item: ChatMessageItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ position: ChatMessageBubbleContentPosition, _ constrainedSize: CGSize) -> (maxWidth: CGFloat, layout: (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))) + + class func asyncLayout(_ maybeNode: ChatMessageActionButtonsNode?) -> (_ replyMarkup: ReplyMarkupMessageAttribute, _ constrainedWidth: CGFloat) -> (minWidth: CGFloat, layout: (CGFloat) -> (CGSize, (_ animated: Bool) -> ChatMessageActionButtonsNode)) { let currentButtonLayouts = maybeNode?.buttonNodes.map { ChatMessageActionButtonNode.asyncLayout($0) } ?? [] return { replyMarkup, constrainedWidth in - var buttonFramesAndApply: [(CGRect, () -> ChatMessageActionButtonNode)] = [] - var verticalRowOffset: CGFloat = 0.0 let buttonHeight: CGFloat = 42.0 let buttonSpacing: CGFloat = 4.0 - verticalRowOffset += buttonSpacing + var overallMinimumRowWidth: CGFloat = 0.0 + + var finalizeRowLayouts: [[((CGFloat) -> (CGSize, () -> ChatMessageActionButtonNode))]] = [] var rowIndex = 0 var buttonIndex = 0 for row in replyMarkup.rows { - var minimumRowWidth: CGFloat = 0.0 + var maximumRowButtonWidth: CGFloat = 0.0 let maximumButtonWidth: CGFloat = max(1.0, floor((constrainedWidth - CGFloat(max(0, row.buttons.count - 1)) * buttonSpacing) / CGFloat(row.buttons.count))) var finalizeRowButtonLayouts: [((CGFloat) -> (CGSize, () -> ChatMessageActionButtonNode))] = [] var rowButtonIndex = 0 @@ -163,77 +166,92 @@ final class ChatMessageActionButtonsNode: ASDisplayNode { prepareButtonLayout = ChatMessageActionButtonNode.asyncLayout(nil)(button, maximumButtonWidth, buttonPosition) } - minimumRowWidth += prepareButtonLayout.minimumWidth + maximumRowButtonWidth = max(maximumRowButtonWidth, prepareButtonLayout.minimumWidth) finalizeRowButtonLayouts.append(prepareButtonLayout.layout) buttonIndex += 1 rowButtonIndex += 1 } - let actualButtonWidth: CGFloat = max(1.0, floor((constrainedWidth - CGFloat(max(0, row.buttons.count - 1)) * buttonSpacing) / CGFloat(row.buttons.count))) - var horizontalButtonOffset: CGFloat = 0.0 - for finalizeButtonLayout in finalizeRowButtonLayouts { - let (buttonSize, buttonApply) = finalizeButtonLayout(actualButtonWidth) - let buttonFrame = CGRect(origin: CGPoint(x: horizontalButtonOffset, y: verticalRowOffset), size: buttonSize) - buttonFramesAndApply.append((buttonFrame, buttonApply)) - horizontalButtonOffset += buttonSize.width + buttonSpacing - } + overallMinimumRowWidth = max(overallMinimumRowWidth, maximumRowButtonWidth * CGFloat(row.buttons.count) + buttonSpacing * max(0.0, CGFloat(row.buttons.count - 1))) + finalizeRowLayouts.append(finalizeRowButtonLayouts) - verticalRowOffset += buttonHeight + buttonSpacing rowIndex += 1 } - if verticalRowOffset > 0.0 { - verticalRowOffset = max(0.0, verticalRowOffset - buttonSpacing) - } - return (CGSize(width: constrainedWidth, height: verticalRowOffset), { animated in - let node: ChatMessageActionButtonsNode - if let maybeNode = maybeNode { - node = maybeNode - } else { - node = ChatMessageActionButtonsNode() - } + return (min(constrainedWidth, overallMinimumRowWidth), { constrainedWidth in + var buttonFramesAndApply: [(CGRect, () -> ChatMessageActionButtonNode)] = [] - var updatedButtons: [ChatMessageActionButtonNode] = [] - var index = 0 - for (buttonFrame, buttonApply) in buttonFramesAndApply { - let buttonNode = buttonApply() - buttonNode.frame = buttonFrame - updatedButtons.append(buttonNode) - if buttonNode.supernode == nil { - node.addSubnode(buttonNode) - buttonNode.pressed = node.buttonPressedWrapper + var verticalRowOffset: CGFloat = 0.0 + verticalRowOffset += buttonSpacing + + var rowIndex = 0 + for finalizeRowButtonLayouts in finalizeRowLayouts { + let actualButtonWidth: CGFloat = max(1.0, floor((constrainedWidth - CGFloat(max(0, finalizeRowButtonLayouts.count - 1)) * buttonSpacing) / CGFloat(finalizeRowButtonLayouts.count))) + var horizontalButtonOffset: CGFloat = 0.0 + for finalizeButtonLayout in finalizeRowButtonLayouts { + let (buttonSize, buttonApply) = finalizeButtonLayout(actualButtonWidth) + let buttonFrame = CGRect(origin: CGPoint(x: horizontalButtonOffset, y: verticalRowOffset), size: buttonSize) + buttonFramesAndApply.append((buttonFrame, buttonApply)) + horizontalButtonOffset += buttonSize.width + buttonSpacing } - index += 1 + + verticalRowOffset += buttonHeight + buttonSpacing + rowIndex += 1 + } + if verticalRowOffset > 0.0 { + verticalRowOffset = max(0.0, verticalRowOffset - buttonSpacing) } - var buttonsUpdated = false - if node.buttonNodes.count != updatedButtons.count { - buttonsUpdated = true - } else { - for i in 0 ..< updatedButtons.count { - if updatedButtons[i] !== node.buttonNodes[i] { - buttonsUpdated = true - break + return (CGSize(width: constrainedWidth, height: verticalRowOffset), { animated in + let node: ChatMessageActionButtonsNode + if let maybeNode = maybeNode { + node = maybeNode + } else { + node = ChatMessageActionButtonsNode() + } + + var updatedButtons: [ChatMessageActionButtonNode] = [] + var index = 0 + for (buttonFrame, buttonApply) in buttonFramesAndApply { + let buttonNode = buttonApply() + buttonNode.frame = buttonFrame + updatedButtons.append(buttonNode) + if buttonNode.supernode == nil { + node.addSubnode(buttonNode) + buttonNode.pressed = node.buttonPressedWrapper + } + index += 1 + } + + var buttonsUpdated = false + if node.buttonNodes.count != updatedButtons.count { + buttonsUpdated = true + } else { + for i in 0 ..< updatedButtons.count { + if updatedButtons[i] !== node.buttonNodes[i] { + buttonsUpdated = true + break + } } } - } - if buttonsUpdated { - for currentButton in node.buttonNodes { - if !updatedButtons.contains(currentButton) { - currentButton.removeFromSupernode() + if buttonsUpdated { + for currentButton in node.buttonNodes { + if !updatedButtons.contains(currentButton) { + currentButton.removeFromSupernode() + } } } - } - node.buttonNodes = updatedButtons - - if animated { - /*UIView.transition(with: node.view, duration: 0.2, options: [.transitionCrossDissolve], animations: { - - }, completion: nil)*/ - } - - return node + node.buttonNodes = updatedButtons + + if animated { + /*UIView.transition(with: node.view, duration: 0.2, options: [.transitionCrossDissolve], animations: { + + }, completion: nil)*/ + } + + return node + }) }) } } diff --git a/TelegramUI/ChatMessageBubbleItemNode.swift b/TelegramUI/ChatMessageBubbleItemNode.swift index ad2e5906af..447c8a42c1 100644 --- a/TelegramUI/ChatMessageBubbleItemNode.swift +++ b/TelegramUI/ChatMessageBubbleItemNode.swift @@ -130,10 +130,14 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) recognizer.tapActionAtPoint = { [weak self] point in if let strongSelf = self { + if let avatarNode = strongSelf.accessoryItemNode as? ChatMessageAvatarAccessoryItemNode, avatarNode.frame.contains(point) { + return .waitForSingleTap + } + if let nameNode = strongSelf.nameNode, nameNode.frame.contains(point) { if let item = strongSelf.item { for attribute in item.message.attributes { - if let attribute = attribute as? InlineBotMessageAttribute { + if let _ = attribute as? InlineBotMessageAttribute { return .waitForSingleTap } } @@ -291,7 +295,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { } if authorNameString != nil || inlineBotNameString != nil { - if headerSize.height < CGFloat(FLT_EPSILON) { + if headerSize.height < CGFloat.ulpOfOne { headerSize.height += 4.0 } @@ -322,7 +326,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { } if let forwardInfo = message.forwardInfo { - if headerSize.height < CGFloat(FLT_EPSILON) { + if headerSize.height < CGFloat.ulpOfOne { headerSize.height += 4.0 } let sizeAndApply = forwardInfoLayout(incoming, forwardInfo.source == nil ? forwardInfo.author : forwardInfo.source!, forwardInfo.source == nil ? nil : forwardInfo.author, CGSize(width: maximumNodeWidth, height: CGFloat.greatestFiniteMagnitude)) @@ -334,12 +338,12 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { } if let replyMessage = replyMessage { - if headerSize.height < CGFloat(FLT_EPSILON) { + if headerSize.height < CGFloat.ulpOfOne { headerSize.height += 6.0 } else { headerSize.height += 2.0 } - let sizeAndApply = replyInfoLayout(incoming, replyMessage, CGSize(width: maximumNodeWidth, height: CGFloat.greatestFiniteMagnitude)) + let sizeAndApply = replyInfoLayout(item.account, .bubble(incoming: incoming), replyMessage, CGSize(width: maximumNodeWidth, height: CGFloat.greatestFiniteMagnitude)) replyInfoSizeApply = (sizeAndApply.0, { sizeAndApply.1() }) replyInfoOriginY = headerSize.height @@ -347,7 +351,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { headerSize.height += replyInfoSizeApply.0.height + 2.0 } - if headerSize.height > CGFloat(FLT_EPSILON) { + if headerSize.height > CGFloat.ulpOfOne { headerSize.height -= 3.0 } } @@ -377,6 +381,13 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { contentNodePropertiesAndFinalize.append((contentNodeProperties, contentNodeFinalize)) } + var actionButtonsFinalize: ((CGFloat) -> (CGSize, (_ animated: Bool) -> ChatMessageActionButtonsNode))? + if let replyMarkup = replyMarkup { + let (minWidth, buttonsLayout) = actionButtonsLayout(replyMarkup, maximumNodeWidth) + maxContentWidth = max(maxContentWidth, minWidth) + actionButtonsFinalize = buttonsLayout + } + var contentSize = CGSize(width: maxContentWidth, height: 0.0) index = 0 var contentNodeSizesPropertiesAndApply: [(CGSize, ChatMessageBubbleContentProperties, (ListViewItemUpdateAnimation) -> Void)] = [] @@ -386,16 +397,16 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { contentSize.height += size.height - if index == 0 && headerSize.height > CGFloat(FLT_EPSILON) { + if index == 0 && headerSize.height > CGFloat.ulpOfOne { contentSize.height += properties.headerSpacing } index += 1 } - var actionButtonsSizeApply: (CGSize, (_ animated: Bool) -> ChatMessageActionButtonsNode)? - if let replyMarkup = replyMarkup { - actionButtonsSizeApply = actionButtonsLayout(replyMarkup, maxContentWidth) + var actionButtonsSizeAndApply: (CGSize, (Bool) -> ChatMessageActionButtonsNode)? + if let actionButtonsFinalize = actionButtonsFinalize { + actionButtonsSizeAndApply = actionButtonsFinalize(maxContentWidth) } let layoutBubbleSize = CGSize(width: max(contentSize.width, headerSize.width) + layoutConstants.bubble.contentInsets.left + layoutConstants.bubble.contentInsets.right, height: max(layoutConstants.bubble.minimumSize.height, headerSize.height + contentSize.height + layoutConstants.bubble.contentInsets.top + layoutConstants.bubble.contentInsets.bottom)) @@ -405,8 +416,8 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { let contentOrigin = CGPoint(x: backgroundFrame.origin.x + (incoming ? layoutConstants.bubble.contentInsets.left : layoutConstants.bubble.contentInsets.right), y: backgroundFrame.origin.y + layoutConstants.bubble.contentInsets.top + headerSize.height) var layoutSize = CGSize(width: width, height: layoutBubbleSize.height) - if let actionButtonsSizeApply = actionButtonsSizeApply { - layoutSize.height += actionButtonsSizeApply.0.height + if let actionButtonsSizeAndApply = actionButtonsSizeAndApply { + layoutSize.height += actionButtonsSizeAndApply.0.height } var layoutInsets = UIEdgeInsets(top: mergedTop ? layoutConstants.bubble.mergedSpacing : layoutConstants.bubble.defaultSpacing, left: 0.0, bottom: mergedBottom ? layoutConstants.bubble.mergedSpacing : layoutConstants.bubble.defaultSpacing, right: 0.0) @@ -509,7 +520,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { var contentNodeIndex = 0 for (size, properties, apply) in contentNodeSizesPropertiesAndApply { apply(animation) - if contentNodeIndex == 0 && headerSize.height > CGFloat(FLT_EPSILON) { + if contentNodeIndex == 0 && headerSize.height > CGFloat.ulpOfOne { contentNodeOrigin.y += properties.headerSpacing } let contentNode = strongSelf.contentNodes[contentNodeIndex] @@ -559,16 +570,16 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { let offset: CGFloat = incoming ? 42.0 : 0.0 strongSelf.selectionNode?.frame = CGRect(origin: CGPoint(x: -offset, y: 0.0), size: CGSize(width: width, height: layout.size.height)) - if let actionButtonsSizeApply = actionButtonsSizeApply { + if let actionButtonsSizeAndApply = actionButtonsSizeAndApply { var animated = false if let _ = strongSelf.actionButtonsNode { if case .System = animation { animated = true } } - let actionButtonsNode = actionButtonsSizeApply.1(animated) + let actionButtonsNode = actionButtonsSizeAndApply.1(animated) let previousFrame = actionButtonsNode.frame - let actionButtonsFrame = CGRect(origin: CGPoint(x: backgroundFrame.minX + (incoming ? layoutConstants.bubble.contentInsets.left : layoutConstants.bubble.contentInsets.right), y: backgroundFrame.maxY), size: actionButtonsSizeApply.0) + let actionButtonsFrame = CGRect(origin: CGPoint(x: backgroundFrame.minX + (incoming ? layoutConstants.bubble.contentInsets.left : layoutConstants.bubble.contentInsets.right), y: backgroundFrame.maxY), size: actionButtonsSizeAndApply.0) actionButtonsNode.frame = actionButtonsFrame if actionButtonsNode !== strongSelf.actionButtonsNode { strongSelf.actionButtonsNode = actionButtonsNode @@ -660,7 +671,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { transitionClippingNode.frame = fixedBackgroundFrame transitionClippingNode.bounds = CGRect(origin: CGPoint(x: fixedBackgroundFrame.origin.x, y: fixedBackgroundFrame.origin.y), size: fixedBackgroundFrame.size) - if progress >= 1.0 - CGFloat(FLT_EPSILON) { + if progress >= 1.0 - CGFloat.ulpOfOne { self.disableTransitionClippingNode() } } @@ -683,6 +694,13 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation { switch gesture { case .tap: + if let avatarNode = self.accessoryItemNode as? ChatMessageAvatarAccessoryItemNode, avatarNode.frame.contains(location) { + if let item = self.item, let author = item.message.author { + self.controllerInteraction?.openPeer(author.id, .info, item.message.id) + } + return + } + if let nameNode = self.nameNode, nameNode.frame.contains(location) { if let item = self.item { for attribute in item.message.attributes { @@ -709,7 +727,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(textInputState: nil)) + self.controllerInteraction?.openPeer(forwardInfo.source?.id ?? forwardInfo.author.id, .chat(textInputState: nil), nil) } return } @@ -729,7 +747,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { case let .peerMention(peerId): foundTapAction = true if let controllerInteraction = self.controllerInteraction { - controllerInteraction.openPeer(peerId, .chat(textInputState: nil)) + controllerInteraction.openPeer(peerId, .chat(textInputState: nil), nil) } break loop case let .textMention(name): @@ -784,6 +802,10 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if let avatarNode = self.accessoryItemNode as? ChatMessageAvatarAccessoryItemNode, avatarNode.frame.contains(point) { + return self.view + } + if let selectionNode = self.selectionNode { if selectionNode.frame.offsetBy(dx: 42.0, dy: 0.0).contains(point) { return selectionNode.view @@ -946,7 +968,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { peerId = item.message.id.peerId } if let botPeer = botPeer, let addressName = botPeer.addressName { - controllerInteraction.openPeer(peerId, .chat(textInputState: ChatTextInputState(inputText: "@\(addressName) \(query)"))) + controllerInteraction.openPeer(peerId, .chat(textInputState: ChatTextInputState(inputText: "@\(addressName) \(query)")), nil) } } } diff --git a/TelegramUI/ChatMessageDateAndStatusNode.swift b/TelegramUI/ChatMessageDateAndStatusNode.swift index 00630a7369..35ecf49d50 100644 --- a/TelegramUI/ChatMessageDateAndStatusNode.swift +++ b/TelegramUI/ChatMessageDateAndStatusNode.swift @@ -55,7 +55,7 @@ private func maybeAddRotationAnimation(_ layer: CALayer, duration: Double) { basicAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut) basicAnimation.duration = duration basicAnimation.fromValue = NSNumber(value: Float(0.0)) - basicAnimation.toValue = NSNumber(value: Float(M_PI * 2.0)) + basicAnimation.toValue = NSNumber(value: Float(Double.pi * 2.0)) basicAnimation.repeatCount = Float.infinity basicAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear) layer.add(basicAnimation, forKey: "clockFrameAnimation") @@ -239,31 +239,40 @@ class ChatMessageDateAndStatusNode: ASTransformLayerNode { } clockPosition = CGPoint(x: leftInset + date.size.width + 8.5, y: 7.5) case let .Sent(read): - statusWidth = 13.0 - - if checkReadNode == nil { - checkReadNode = ASImageNode() - checkReadNode?.isLayerBacked = true - checkReadNode?.displaysAsynchronously = false - checkReadNode?.displayWithoutProcessing = true + if impressionCount != nil { + statusWidth = 0.0 + + checkReadNode = nil + checkSentNode = nil + clockFrameNode = nil + clockMinNode = nil + } else { + statusWidth = 13.0 + + if checkReadNode == nil { + checkReadNode = ASImageNode() + checkReadNode?.isLayerBacked = true + checkReadNode?.displaysAsynchronously = false + checkReadNode?.displayWithoutProcessing = true + } + + if checkSentNode == nil { + checkSentNode = ASImageNode() + checkSentNode?.isLayerBacked = true + checkSentNode?.displaysAsynchronously = false + checkSentNode?.displayWithoutProcessing = true + } + + clockFrameNode = nil + clockMinNode = nil + + let checkSize = loadedCheckFullImage!.size + + if read { + checkReadFrame = CGRect(origin: CGPoint(x: leftInset + date.size.width + 5.0 + statusWidth - checkSize.width, y: 3.0), size: checkSize) + } + checkSentFrame = CGRect(origin: CGPoint(x: leftInset + date.size.width + 5.0 + statusWidth - checkSize.width - 6.0, y: 3.0), size: checkSize) } - - if checkSentNode == nil { - checkSentNode = ASImageNode() - checkSentNode?.isLayerBacked = true - checkSentNode?.displaysAsynchronously = false - checkSentNode?.displayWithoutProcessing = true - } - - clockFrameNode = nil - clockMinNode = nil - - let checkSize = loadedCheckFullImage!.size - - if read { - checkReadFrame = CGRect(origin: CGPoint(x: leftInset + date.size.width + 5.0 + statusWidth - checkSize.width, y: 3.0), size: checkSize) - } - checkSentFrame = CGRect(origin: CGPoint(x: leftInset + date.size.width + 5.0 + statusWidth - checkSize.width - 6.0, y: 3.0), size: checkSize) case .Failed: statusWidth = 0.0 diff --git a/TelegramUI/ChatMessageInteractiveFileNode.swift b/TelegramUI/ChatMessageInteractiveFileNode.swift index 6f00de8c4e..5245e67680 100644 --- a/TelegramUI/ChatMessageInteractiveFileNode.swift +++ b/TelegramUI/ChatMessageInteractiveFileNode.swift @@ -160,17 +160,23 @@ final class ChatMessageInteractiveFileNode: ASTransformNode { localtime_r(&t, &timeinfo) var edited = false + var sentViaBot = false var viewCount: Int? for attribute in message.attributes { if let _ = attribute as? EditedMessageAttribute { edited = true } else if let attribute = attribute as? ViewCountMessageAttribute { viewCount = attribute.count + } else if let _ = attribute as? InlineBotMessageAttribute { + sentViaBot = true } } + if let author = message.author as? TelegramUser, author.botInfo != nil { + sentViaBot = true + } let dateText = String(format: "%02d:%02d", arguments: [Int(timeinfo.tm_hour), Int(timeinfo.tm_min)]) - let (size, apply) = statusLayout(edited, viewCount, dateText, statusType, constrainedSize) + let (size, apply) = statusLayout(edited && !sentViaBot, viewCount, dateText, statusType, constrainedSize) statusSize = size statusApply = apply } diff --git a/TelegramUI/ChatMessageItemView.swift b/TelegramUI/ChatMessageItemView.swift index 36ec89871f..b1801cd963 100644 --- a/TelegramUI/ChatMessageItemView.swift +++ b/TelegramUI/ChatMessageItemView.swift @@ -63,7 +63,6 @@ public class ChatMessageItemView: ListViewItemNode { public init(layerBacked: Bool) { super.init(layerBacked: layerBacked, dynamicBounce: true, rotated: true) - self.transform = CATransform3DMakeRotation(CGFloat(M_PI), 0.0, 0.0, 1.0) } diff --git a/TelegramUI/ChatMessageMediaBubbleContentNode.swift b/TelegramUI/ChatMessageMediaBubbleContentNode.swift index 0bf97edb34..5731394a62 100644 --- a/TelegramUI/ChatMessageMediaBubbleContentNode.swift +++ b/TelegramUI/ChatMessageMediaBubbleContentNode.swift @@ -66,19 +66,25 @@ class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { localtime_r(&t, &timeinfo) var edited = false + var sentViaBot = false var viewCount: Int? for attribute in item.message.attributes { if let _ = attribute as? EditedMessageAttribute { edited = true } else if let attribute = attribute as? ViewCountMessageAttribute { viewCount = attribute.count + } else if let _ = attribute as? InlineBotMessageAttribute { + sentViaBot = true } } - var dateText = String(format: "%02d:%02d", arguments: [Int(timeinfo.tm_hour), Int(timeinfo.tm_min)]) + if let author = item.message.author as? TelegramUser, author.botInfo != nil { + sentViaBot = true + } + let dateText = String(format: "%02d:%02d", arguments: [Int(timeinfo.tm_hour), Int(timeinfo.tm_min)]) let statusType: ChatMessageDateAndStatusType? if case .None = position.bottom { - if item.message.flags.contains(.Incoming) { + if item.message.effectivelyIncoming { statusType = .ImageIncoming } else { if item.message.flags.contains(.Failed) { @@ -99,7 +105,7 @@ class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { var statusApply: ((Bool) -> Void)? if let statusType = statusType { - let (size, apply) = statusLayout(edited, viewCount, dateText, statusType, CGSize(width: imageLayoutSize.width, height: CGFloat.greatestFiniteMagnitude)) + let (size, apply) = statusLayout(edited && !sentViaBot, viewCount, dateText, statusType, CGSize(width: imageLayoutSize.width, height: CGFloat.greatestFiniteMagnitude)) statusSize = size statusApply = apply } diff --git a/TelegramUI/ChatMessageReplyInfoNode.swift b/TelegramUI/ChatMessageReplyInfoNode.swift index 25cc300068..0eb4ce4718 100644 --- a/TelegramUI/ChatMessageReplyInfoNode.swift +++ b/TelegramUI/ChatMessageReplyInfoNode.swift @@ -2,6 +2,8 @@ import Foundation import AsyncDisplayKit import Postbox import Display +import TelegramCore +import SwiftSignalKit private let titleFont: UIFont = { if #available(iOS 8.2, *) { @@ -12,11 +14,75 @@ private let titleFont: UIFont = { }() private let textFont = Font.regular(14.0) +private func textStringForMessage(_ message: Message) -> (String, Bool) { + if !message.text.isEmpty { + return (message.text, false) + } else { + for media in message.media { + switch media { + case _ as TelegramMediaImage: + return ("Photo", true) + case let file as TelegramMediaFile: + var fileName: String = "File" + for attribute in file.attributes { + switch attribute { + case let .Sticker(text, _): + return ("\(text) Sticker", true) + case let .FileName(name): + fileName = name + case let .Audio(isVoice, _, title, performer, _): + if isVoice { + return ("Voice Message", true) + } else { + if let title = title, let performer = performer, !title.isEmpty, !performer.isEmpty { + return (title + " — " + performer, true) + } else if let title = title, !title.isEmpty { + return (title, true) + } else if let performer = performer, !performer.isEmpty { + return (performer, true) + } else { + return ("Audio", true) + } + } + case .Video: + if file.isAnimated { + return ("GIF", true) + } else { + return ("Video", true) + } + default: + break + } + } + return (fileName, true) + case _ as TelegramMediaContact: + return ("Contact", true) + case let game as TelegramMediaGame: + return (game.title, true) + case _ as TelegramMediaMap: + return ("Map", true) + case let action as TelegramMediaAction: + return ("", true) + default: + break + } + } + return ("", false) + } +} + +enum ChatMessageReplyInfoType { + case bubble(incoming: Bool) + case standalone +} + class ChatMessageReplyInfoNode: ASTransformLayerNode { private let contentNode: ASDisplayNode private let lineNode: ASDisplayNode private var titleNode: TextNode? private var textNode: TextNode? + private var imageNode: TransformImageNode? + private var previousMedia: Media? override init() { self.contentNode = ASDisplayNode() @@ -35,25 +101,81 @@ class ChatMessageReplyInfoNode: ASTransformLayerNode { self.contentNode.addSubnode(self.lineNode) } - class func asyncLayout(_ maybeNode: ChatMessageReplyInfoNode?) -> (_ incoming: Bool, _ message: Message, _ constrainedSize: CGSize) -> (CGSize, () -> ChatMessageReplyInfoNode) { + class func asyncLayout(_ maybeNode: ChatMessageReplyInfoNode?) -> (_ account: Account, _ type: ChatMessageReplyInfoType, _ message: Message, _ constrainedSize: CGSize) -> (CGSize, () -> ChatMessageReplyInfoNode) { let titleNodeLayout = TextNode.asyncLayout(maybeNode?.titleNode) let textNodeLayout = TextNode.asyncLayout(maybeNode?.textNode) + let imageNodeLayout = TransformImageNode.asyncLayout(maybeNode?.imageNode) + let previousMedia = maybeNode?.previousMedia - return { incoming, message, constrainedSize in + return { account, type, message, constrainedSize in let titleString = message.author?.displayTitle ?? "" - let textString = message.text - let titleColor = incoming ? UIColor(0x007bff) : UIColor(0x00a516) + let (textString, textMedia) = textStringForMessage(message) - let leftInset: CGFloat = 10.0 - let lineColor = incoming ? UIColor(0x3ca7fe) : UIColor(0x29cc10) + let titleColor: UIColor + let lineColor: UIColor + let textColor: UIColor + + switch type { + case let .bubble(incoming): + titleColor = incoming ? UIColor(0x007bff) : UIColor(0x00a516) + lineColor = incoming ? UIColor(0x3ca7fe) : UIColor(0x29cc10) + textColor = .black + case .standalone: + titleColor = .white + lineColor = .white + textColor = .white + } + + var leftInset: CGFloat = 10.0 + + var updatedMedia: Media? + var imageDimensions: CGSize? + for media in message.media { + if let image = media as? TelegramMediaImage { + updatedMedia = image + if let representation = largestRepresentationForPhoto(image) { + imageDimensions = representation.dimensions + } + break + } else if let file = media as? TelegramMediaFile { + updatedMedia = file + if let representation = largestImageRepresentation(file.previewRepresentations), !file.isSticker { + imageDimensions = representation.dimensions + } + break + } + } + + var applyImage: (() -> TransformImageNode)? + if let imageDimensions = imageDimensions { + leftInset += 36.0 + let boundingSize = CGSize(width: 30.0, height: 30.0) + applyImage = imageNodeLayout(TransformImageArguments(corners: ImageCorners(radius: 2.0), imageSize: imageDimensions.aspectFilled(boundingSize), boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets())) + } + + var mediaUpdated = false + if let updatedMedia = updatedMedia, let previousMedia = previousMedia { + mediaUpdated = !updatedMedia.isEqual(previousMedia) + } else if (updatedMedia != nil) != (previousMedia != nil) { + mediaUpdated = true + } + + var updateImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? + if let updatedMedia = updatedMedia, mediaUpdated && imageDimensions != nil { + if let image = updatedMedia as? TelegramMediaImage { + updateImageSignal = chatMessagePhotoThumbnail(account: account, photo: image) + } else if let file = updatedMedia as? TelegramMediaFile { + + } + } let maximumTextWidth = max(0.0, constrainedSize.width - leftInset) let contrainedTextSize = CGSize(width: maximumTextWidth, height: constrainedSize.height) let (titleLayout, titleApply) = titleNodeLayout(NSAttributedString(string: titleString, font: titleFont, textColor: titleColor), nil, 1, .end, contrainedTextSize, nil) - let (textLayout, textApply) = textNodeLayout(NSAttributedString(string: textString, font: textFont, textColor: UIColor.black), nil, 1, .end, contrainedTextSize, nil) + let (textLayout, textApply) = textNodeLayout(NSAttributedString(string: textString, font: textFont, textColor: textMedia ? titleColor : textColor), nil, 1, .end, contrainedTextSize, nil) let size = CGSize(width: max(titleLayout.size.width, textLayout.size.width) + leftInset, height: titleLayout.size.height + textLayout.size.height) @@ -65,6 +187,8 @@ class ChatMessageReplyInfoNode: ASTransformLayerNode { node = ChatMessageReplyInfoNode() } + node.previousMedia = updatedMedia + let titleNode = titleApply() let textNode = textApply() @@ -80,11 +204,28 @@ class ChatMessageReplyInfoNode: ASTransformLayerNode { node.contentNode.addSubnode(textNode) } + if let applyImage = applyImage { + let imageNode = applyImage() + if node.imageNode == nil { + imageNode.isLayerBacked = true + node.addSubnode(imageNode) + node.imageNode = imageNode + } + imageNode.frame = CGRect(origin: CGPoint(x: 8.0, y: 3.0), size: CGSize(width: 30.0, height: 30.0)) + + if let updateImageSignal = updateImageSignal { + imageNode.setSignal(account: account, signal: updateImageSignal) + } + } else if let imageNode = node.imageNode { + imageNode.removeFromSupernode() + node.imageNode = nil + } + titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 0.0), size: titleLayout.size) textNode.frame = CGRect(origin: CGPoint(x: leftInset, y: titleLayout.size.height), size: textLayout.size) node.lineNode.backgroundColor = lineColor - node.lineNode.frame = CGRect(origin: CGPoint(x: 1.0, y: 2.5), size: CGSize(width: 2.0, height: size.height - 3.0)) + node.lineNode.frame = CGRect(origin: CGPoint(x: 1.0, y: 3.0), size: CGSize(width: 2.0, height: size.height - 4.0)) node.contentNode.frame = CGRect(origin: CGPoint(), size: size) diff --git a/TelegramUI/ChatMessageStickerItemNode.swift b/TelegramUI/ChatMessageStickerItemNode.swift index 200f23eb1e..8b297e9f1c 100644 --- a/TelegramUI/ChatMessageStickerItemNode.swift +++ b/TelegramUI/ChatMessageStickerItemNode.swift @@ -5,15 +5,22 @@ import SwiftSignalKit import Postbox import TelegramCore +private let backgroundImage = generateStretchableFilledCircleImage(radius: 4.0, color: UIColor(0x748391, 0.45)) + class ChatMessageStickerItemNode: ChatMessageItemView { let imageNode: TransformImageNode var progressNode: RadialProgressNode? var tapRecognizer: UITapGestureRecognizer? + private var selectionNode: ChatMessageSelectionNode? + var telegramFile: TelegramMediaFile? private let fetchDisposable = MetaDisposable() + private var replyInfoNode: ChatMessageReplyInfoNode? + private var replyBackgroundNode: ASImageNode? + required init() { self.imageNode = TransformImageNode() @@ -31,6 +38,16 @@ class ChatMessageStickerItemNode: ChatMessageItemView { self.fetchDisposable.dispose() } + override func didLoad() { + super.didLoad() + + let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) + recognizer.tapActionAtPoint = { _ in + return .waitForSingleTap + } + self.view.addGestureRecognizer(recognizer) + } + override func setupItem(_ item: ChatMessageItem) { super.setupItem(item) @@ -55,6 +72,9 @@ class ChatMessageStickerItemNode: ChatMessageItemView { let layoutConstants = self.layoutConstants let imageLayout = self.imageNode.asyncLayout() + let makeReplyInfoLayout = ChatMessageReplyInfoNode.asyncLayout(self.replyInfoNode) + let currentReplyBackgroundNode = self.replyBackgroundNode + return { item, width, mergedTop, mergedBottom, dateHeaderAtBottom in let incoming = item.message.effectivelyIncoming var imageSize: CGSize = CGSize(width: 100.0, height: 100.0) @@ -73,17 +93,60 @@ class ChatMessageStickerItemNode: ChatMessageItemView { layoutInsets.top += layoutConstants.timestampHeaderHeight } - let imageFrame = CGRect(origin: CGPoint(x: (incoming ? (layoutConstants.bubble.edgeInset + avatarInset) : (width - imageSize.width - layoutConstants.bubble.edgeInset)), y: 0.0), size: imageSize) + let imageFrame = CGRect(origin: CGPoint(x: (incoming ? (layoutConstants.bubble.edgeInset + avatarInset + layoutConstants.bubble.contentInsets.left) : (width - imageSize.width - layoutConstants.bubble.edgeInset - layoutConstants.bubble.contentInsets.left)), y: 0.0), size: imageSize) let arguments = TransformImageArguments(corners: ImageCorners(), imageSize: imageFrame.size, boundingSize: imageFrame.size, intrinsicInsets: UIEdgeInsets()) let imageApply = imageLayout(arguments) + var replyInfoApply: (CGSize, () -> ChatMessageReplyInfoNode)? + var updatedReplyBackgroundNode: ASImageNode? + var replyBackgroundImage: UIImage? + for attribute in item.message.attributes { + if let replyAttribute = attribute as? ReplyMessageAttribute, let replyMessage = item.message.associatedMessages[replyAttribute.messageId] { + let availableWidth = max(60.0, width - imageSize.width - 20.0 - layoutConstants.bubble.edgeInset * 2.0 - avatarInset - layoutConstants.bubble.contentInsets.left) + replyInfoApply = makeReplyInfoLayout(item.account, .standalone, replyMessage, CGSize(width: availableWidth, height: CGFloat.greatestFiniteMagnitude)) + + if let currentReplyBackgroundNode = currentReplyBackgroundNode { + updatedReplyBackgroundNode = currentReplyBackgroundNode + } else { + updatedReplyBackgroundNode = ASImageNode() + } + replyBackgroundImage = backgroundImage + break + } + } + return (ListViewItemNodeLayout(contentSize: CGSize(width: width, height: imageSize.height), insets: layoutInsets), { [weak self] animation in if let strongSelf = self { strongSelf.imageNode.frame = imageFrame strongSelf.progressNode?.position = strongSelf.imageNode.position imageApply() + + if let updatedReplyBackgroundNode = updatedReplyBackgroundNode { + if strongSelf.replyBackgroundNode == nil { + strongSelf.replyBackgroundNode = updatedReplyBackgroundNode + strongSelf.addSubnode(updatedReplyBackgroundNode) + updatedReplyBackgroundNode.image = replyBackgroundImage + } + } else if let replyBackgroundNode = strongSelf.replyBackgroundNode { + replyBackgroundNode.removeFromSupernode() + strongSelf.replyBackgroundNode = nil + } + + if let (replyInfoSize, replyInfoApply) = replyInfoApply { + let replyInfoNode = replyInfoApply() + if strongSelf.replyInfoNode == nil { + strongSelf.replyInfoNode = replyInfoNode + strongSelf.addSubnode(replyInfoNode) + } + let replyInfoFrame = CGRect(origin: CGPoint(x: (!incoming ? (layoutConstants.bubble.edgeInset + 10.0) : (width - replyInfoSize.width - layoutConstants.bubble.edgeInset - 10.0)), y: imageSize.height - replyInfoSize.height - 8.0), size: replyInfoSize) + replyInfoNode.frame = replyInfoFrame + strongSelf.replyBackgroundNode?.frame = CGRect(origin: CGPoint(x: replyInfoFrame.minX - 4.0, y: replyInfoFrame.minY - 2.0), size: CGSize(width: replyInfoFrame.size.width + 8.0, height: replyInfoFrame.size.height + 5.0)) + } else if let replyInfoNode = strongSelf.replyInfoNode { + replyInfoNode.removeFromSupernode() + strongSelf.replyInfoNode = nil + } } }) } @@ -100,4 +163,130 @@ class ChatMessageStickerItemNode: ChatMessageItemView { self.imageNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } + + @objc func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { + switch recognizer.state { + case .ended: + if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation { + switch gesture { + case .tap: + /*if let nameNode = self.nameNode, nameNode.frame.contains(location) { + if let item = self.item { + for attribute in item.message.attributes { + if let attribute = attribute as? InlineBotMessageAttribute, let botPeer = item.message.peers[attribute.peerId], let addressName = botPeer.addressName { + self.controllerInteraction?.updateInputState { textInputState in + return ChatTextInputState(inputText: "@" + addressName + " ") + } + return + } + } + } + } else */ + + if let replyInfoNode = self.replyInfoNode, replyInfoNode.frame.contains(location) { + if let item = self.item { + for attribute in item.message.attributes { + if let attribute = attribute as? ReplyMessageAttribute { + self.controllerInteraction?.navigateToMessage(item.message.id, attribute.messageId) + return + } + } + } + } + + /*if let forwardInfoNode = self.forwardInfoNode, forwardInfoNode.frame.contains(location) { + if let item = self.item, let forwardInfo = item.message.forwardInfo { + if let sourceMessageId = forwardInfo.sourceMessageId { + self.controllerInteraction?.navigateToMessage(item.message.id, sourceMessageId) + } else { + self.controllerInteraction?.openPeer(forwardInfo.source?.id ?? forwardInfo.author.id, .chat(textInputState: nil)) + } + return + } + }*/ + + if let item = self.item, self.imageNode.frame.contains(location) { + self.controllerInteraction?.openMessage(item.message.id) + return + } + + self.controllerInteraction?.clickThroughMessage() + case .longTap, .doubleTap: + if let item = self.item, self.imageNode.frame.contains(location) { + self.controllerInteraction?.openMessageContextMenu(item.message.id, self, self.imageNode.frame) + } + case .hold: + break + } + } + default: + break + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return super.hitTest(point, with: event) + } + + override func updateSelectionState(animated: Bool) { + guard let controllerInteraction = self.controllerInteraction else { + return + } + + if let selectionState = controllerInteraction.selectionState { + var selected = false + var incoming = true + if let item = self.item { + selected = selectionState.selectedIds.contains(item.message.id) + incoming = item.message.effectivelyIncoming + } + let offset: CGFloat = incoming ? 42.0 : 0.0 + + if let selectionNode = self.selectionNode { + selectionNode.updateSelected(selected, animated: false) + selectionNode.frame = CGRect(origin: CGPoint(x: -offset, y: 0.0), size: CGSize(width: self.contentBounds.size.width, height: self.contentBounds.size.height)) + self.subnodeTransform = CATransform3DMakeTranslation(offset, 0.0, 0.0); + } else { + let selectionNode = ChatMessageSelectionNode(toggle: { [weak self] in + if let strongSelf = self, let item = strongSelf.item { + strongSelf.controllerInteraction?.toggleMessageSelection(item.message.id) + } + }) + + selectionNode.frame = CGRect(origin: CGPoint(x: -offset, y: 0.0), size: CGSize(width: self.contentBounds.size.width, height: self.contentBounds.size.height)) + self.addSubnode(selectionNode) + self.selectionNode = selectionNode + selectionNode.updateSelected(selected, animated: false) + let previousSubnodeTransform = self.subnodeTransform + self.subnodeTransform = CATransform3DMakeTranslation(offset, 0.0, 0.0); + if animated { + selectionNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + self.layer.animate(from: NSValue(caTransform3D: previousSubnodeTransform), to: NSValue(caTransform3D: self.subnodeTransform), keyPath: "sublayerTransform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.4) + + if !incoming { + let position = selectionNode.layer.position + selectionNode.layer.animatePosition(from: CGPoint(x: position.x - 42.0, y: position.y), to: position, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) + } + } + } + } else { + if let selectionNode = self.selectionNode { + self.selectionNode = nil + let previousSubnodeTransform = self.subnodeTransform + self.subnodeTransform = CATransform3DIdentity + if animated { + self.layer.animate(from: NSValue(caTransform3D: previousSubnodeTransform), to: NSValue(caTransform3D: self.subnodeTransform), keyPath: "sublayerTransform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.4, completion: { [weak selectionNode]_ in + selectionNode?.removeFromSupernode() + }) + selectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) + if CGFloat(0.0).isLessThanOrEqualTo(selectionNode.frame.origin.x) { + let position = selectionNode.layer.position + selectionNode.layer.animatePosition(from: position, to: CGPoint(x: position.x - 42.0, y: position.y), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + } + } else { + selectionNode.removeFromSupernode() + } + } + } + } } diff --git a/TelegramUI/ChatMessageTextBubbleContentNode.swift b/TelegramUI/ChatMessageTextBubbleContentNode.swift index edfaf4fa0a..13fc18b566 100644 --- a/TelegramUI/ChatMessageTextBubbleContentNode.swift +++ b/TelegramUI/ChatMessageTextBubbleContentNode.swift @@ -47,15 +47,21 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { localtime_r(&t, &timeinfo) var edited = false + var sentViaBot = false var viewCount: Int? - for attribute in message.attributes { + for attribute in item.message.attributes { if let _ = attribute as? EditedMessageAttribute { edited = true } else if let attribute = attribute as? ViewCountMessageAttribute { viewCount = attribute.count + } else if let _ = attribute as? InlineBotMessageAttribute { + sentViaBot = true } } - var dateText = String(format: "%02d:%02d", arguments: [Int(timeinfo.tm_hour), Int(timeinfo.tm_min)]) + if let author = item.message.author as? TelegramUser, author.botInfo != nil { + sentViaBot = true + } + let dateText = String(format: "%02d:%02d", arguments: [Int(timeinfo.tm_hour), Int(timeinfo.tm_min)]) let statusType: ChatMessageDateAndStatusType? if case .None = position.bottom { @@ -78,7 +84,7 @@ class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { var statusApply: ((Bool) -> Void)? if let statusType = statusType { - let (size, apply) = statusLayout(edited, viewCount, dateText, statusType, textConstrainedSize) + let (size, apply) = statusLayout(edited && !sentViaBot, viewCount, dateText, statusType, textConstrainedSize) statusSize = size statusApply = apply } diff --git a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift index 104c050798..115e353806 100644 --- a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift +++ b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift @@ -94,14 +94,20 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { localtime_r(&t, &timeinfo) var edited = false + var sentViaBot = false var viewCount: Int? for attribute in item.message.attributes { if let _ = attribute as? EditedMessageAttribute { edited = true } else if let attribute = attribute as? ViewCountMessageAttribute { viewCount = attribute.count + } else if let _ = attribute as? InlineBotMessageAttribute { + sentViaBot = true } } + if let author = item.message.author as? TelegramUser, author.botInfo != nil { + sentViaBot = true + } let dateText = String(format: "%02d:%02d", arguments: [Int(timeinfo.tm_hour), Int(timeinfo.tm_min)]) var textString: NSAttributedString? @@ -174,7 +180,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { return (initialWidth, { constrainedSize in let statusType: ChatMessageDateAndStatusType - if item.message.flags.contains(.Incoming) { + if item.message.effectivelyIncoming { statusType = .BubbleIncoming } else { if item.message.flags.contains(.Failed) { @@ -191,7 +197,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { var statusSizeAndApply: (CGSize, (Bool) -> Void)? if refineContentImageLayout == nil && refineContentFileLayout == nil { - statusSizeAndApply = statusLayout(edited, viewCount, dateText, statusType, textConstrainedSize) + statusSizeAndApply = statusLayout(edited && !sentViaBot, viewCount, dateText, statusType, textConstrainedSize) } let (textLayout, textApply) = textAsyncLayout(textString, nil, 12, .end, textConstrainedSize, textCutout) @@ -281,7 +287,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { contentImageSizeAndApply = (size, apply) var imageHeigthAddition = size.height - if textFrame.size.height > CGFloat(FLT_EPSILON) { + if textFrame.size.height > CGFloat.ulpOfOne { imageHeigthAddition += 2.0 } @@ -295,7 +301,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { contentFileSizeAndApply = (size, apply) var imageHeigthAddition = size.height - if textFrame.size.height > CGFloat(FLT_EPSILON) { + if textFrame.size.height > CGFloat.ulpOfOne { imageHeigthAddition += 2.0 } @@ -369,7 +375,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { } } let _ = contentImageApply() - contentImageNode.frame = CGRect(origin: CGPoint(x: insets.left, y: textFrame.maxY + (textFrame.size.height > CGFloat(FLT_EPSILON) ? 4.0 : 0.0)), size: contentImageSize) + contentImageNode.frame = CGRect(origin: CGPoint(x: insets.left, y: textFrame.maxY + (textFrame.size.height > CGFloat.ulpOfOne ? 4.0 : 0.0)), size: contentImageSize) } else if let contentImageNode = strongSelf.contentImageNode { contentImageNode.removeFromSupernode() strongSelf.contentImageNode = nil @@ -387,7 +393,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { } } let _ = contentFileApply() - contentFileNode.frame = CGRect(origin: CGPoint(x: insets.left, y: textFrame.maxY + (textFrame.size.height > CGFloat(FLT_EPSILON) ? 4.0 : 0.0)), size: contentFileSize) + contentFileNode.frame = CGRect(origin: CGPoint(x: insets.left, y: textFrame.maxY + (textFrame.size.height > CGFloat.ulpOfOne ? 4.0 : 0.0)), size: contentFileSize) } else if let contentFileNode = strongSelf.contentFileNode { contentFileNode.removeFromSupernode() strongSelf.contentFileNode = nil diff --git a/TelegramUI/ChatTitleView.swift b/TelegramUI/ChatTitleView.swift index 145845f4cb..a8a474d60f 100644 --- a/TelegramUI/ChatTitleView.swift +++ b/TelegramUI/ChatTitleView.swift @@ -34,7 +34,7 @@ final class ChatTitleView: UIView { case .recordingVoice: stringValue = "recording audio" default: - stringValue = "typing..." + stringValue = "typing" } } else { for (peer, _) in inputActivities { diff --git a/TelegramUI/ContactMultiselectionController.swift b/TelegramUI/ContactMultiselectionController.swift index 37247c922c..c7b33c6280 100644 --- a/TelegramUI/ContactMultiselectionController.swift +++ b/TelegramUI/ContactMultiselectionController.swift @@ -7,6 +7,7 @@ import TelegramCore public enum ContactMultiselectionControllerMode { case groupCreation + case peerSelection } public class ContactMultiselectionController: ViewController { @@ -33,6 +34,8 @@ public class ContactMultiselectionController: ViewController { private var rightNavigationButton: UIBarButtonItem? + private var didPlayPresentationAnimation = false + public init(account: Account, mode: ContactMultiselectionControllerMode) { self.account = account self.mode = mode @@ -48,6 +51,13 @@ public class ContactMultiselectionController: ViewController { self.rightNavigationButton = rightNavigationButton self.navigationItem.rightBarButtonItem = self.rightNavigationButton rightNavigationButton.isEnabled = false + case .peerSelection: + self.titleView.title = CounterContollerTitle(title: "Add Users", counter: "") + let rightNavigationButton = UIBarButtonItem(title: "Done", style: .done, target: self, action: #selector(self.rightNavigationButtonPressed)) + self.rightNavigationButton = rightNavigationButton + self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Cancel", style: .plain, target: self, action: #selector(cancelPressed)) + self.navigationItem.rightBarButtonItem = self.rightNavigationButton + rightNavigationButton.isEnabled = false } self.navigationItem.titleView = self.titleView @@ -68,6 +78,10 @@ public class ContactMultiselectionController: ViewController { self.displayNode = ContactMultiselectionControllerNode(account: self.account) self._ready.set(self.contactsNode.contactListNode.ready) + self.contactsNode.dismiss = { [weak self] in + self?.presentingViewController?.dismiss(animated: true, completion: nil) + } + self.contactsNode.openPeer = { [weak self] peer in if let strongSelf = self { var updatedCount: Int? @@ -98,7 +112,12 @@ public class ContactMultiselectionController: ViewController { if let updatedCount = updatedCount { strongSelf.rightNavigationButton?.isEnabled = updatedCount != 0 - strongSelf.titleView.title = CounterContollerTitle(title: "New Group", counter: "\(updatedCount)/5000") + switch strongSelf.mode { + case .groupCreation: + strongSelf.titleView.title = CounterContollerTitle(title: "New Group", counter: "\(updatedCount)/5000") + case .peerSelection: + break + } } if let addedToken = addedToken { @@ -139,7 +158,12 @@ public class ContactMultiselectionController: ViewController { if let updatedCount = updatedCount { strongSelf.rightNavigationButton?.isEnabled = updatedCount != 0 - strongSelf.titleView.title = CounterContollerTitle(title: "New Group", counter: "\(updatedCount)/5000") + switch strongSelf.mode { + case .groupCreation: + strongSelf.titleView.title = CounterContollerTitle(title: "New Group", counter: "\(updatedCount)/5000") + case .peerSelection: + break + } } if let removedTokenId = removedTokenId { @@ -160,6 +184,21 @@ public class ContactMultiselectionController: ViewController { self.contactsNode.contactListNode.enableUpdates = true } + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if let presentationArguments = self.presentationArguments as? ViewControllerPresentationArguments, !self.didPlayPresentationAnimation { + self.didPlayPresentationAnimation = true + if case .modalSheet = presentationArguments.presentationAnimation { + self.contactsNode.animateIn() + } + } + } + + override open func dismiss() { + self.contactsNode.animateOut() + } + override public func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) @@ -172,6 +211,10 @@ public class ContactMultiselectionController: ViewController { self.contactsNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationHeight, transition: transition) } + @objc func cancelPressed() { + self._result.set(.single([])) + } + @objc func rightNavigationButtonPressed() { var peerIds: [PeerId] = [] self.contactsNode.contactListNode.updateSelectionState { state in diff --git a/TelegramUI/ContactMultiselectionControllerNode.swift b/TelegramUI/ContactMultiselectionControllerNode.swift index ff04463b10..d8e78d0094 100644 --- a/TelegramUI/ContactMultiselectionControllerNode.swift +++ b/TelegramUI/ContactMultiselectionControllerNode.swift @@ -40,6 +40,8 @@ final class ContactMultiselectionControllerNode: ASDisplayNode { private let searchResultsReadyDisposable = MetaDisposable() + var dismiss: (() -> Void)? + init(account: Account) { self.account = account self.contactListNode = ContactListNode(account: account, presentation: .natural(displaySearch: false, options: []), selectionState: ContactListNodeGroupSelectionState()) @@ -130,4 +132,16 @@ final class ContactMultiselectionControllerNode: ASDisplayNode { searchResultsNode.frame = CGRect(origin: CGPoint(), size: layout.size) } } + + func animateIn() { + self.layer.animatePosition(from: CGPoint(x: self.layer.position.x, y: self.layer.position.y + self.layer.bounds.size.height), to: self.layer.position, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring) + } + + func animateOut() { + self.layer.animatePosition(from: self.layer.position, to: CGPoint(x: self.layer.position.x, y: self.layer.position.y + self.layer.bounds.size.height), duration: 0.2, timingFunction: kCAMediaTimingFunctionEaseInEaseOut, removeOnCompletion: false, completion: { [weak self] _ in + if let strongSelf = self { + strongSelf.dismiss?() + } + }) + } } diff --git a/TelegramUI/ContactSelectionController.swift b/TelegramUI/ContactSelectionController.swift index f84fac127e..c50df545c9 100644 --- a/TelegramUI/ContactSelectionController.swift +++ b/TelegramUI/ContactSelectionController.swift @@ -177,7 +177,7 @@ public class ContactSelectionController: ViewController { })) } - public func dismiss() { + override open func dismiss() { if let presentationArguments = self.presentationArguments as? ViewControllerPresentationArguments { switch presentationArguments.presentationAnimation { case .modalSheet: diff --git a/TelegramUI/ConvertToSupergroupController.swift b/TelegramUI/ConvertToSupergroupController.swift index 405a544e40..64c19f6c59 100644 --- a/TelegramUI/ConvertToSupergroupController.swift +++ b/TelegramUI/ConvertToSupergroupController.swift @@ -53,13 +53,13 @@ private enum ConvertToSupergroupEntry: ItemListNodeEntry { func item(_ arguments: ConvertToSupergroupArguments) -> ListViewItem { switch self { case .info: - return ItemListTextItem(text: "In supergroups:\n• New members can see the full message history\n• Deleted messages will disappear for all members\n• Admins can pin important messages\n• Creator can set a public link for the group", sectionId: self.section) + return ItemListTextItem(text: .plain("In supergroups:\n• New members can see the full message history\n• Deleted messages will disappear for all members\n• Admins can pin important messages\n• Creator can set a public link for the group"), sectionId: self.section) case .action: return ItemListActionItem(title: "Convert to Supergroup", kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { arguments.convert() }) case .actionInfo: - return ItemListTextItem(text: "Note: this action can't be undone", sectionId: self.section) + return ItemListTextItem(text: .plain("Note: this action can't be undone"), sectionId: self.section) } } } diff --git a/TelegramUI/CreateChannelController.swift b/TelegramUI/CreateChannelController.swift index bf3f8db2b9..edbafad043 100644 --- a/TelegramUI/CreateChannelController.swift +++ b/TelegramUI/CreateChannelController.swift @@ -106,7 +106,7 @@ private enum CreateChannelEntry: ItemListNodeEntry { }) case .descriptionInfo: - return ItemListTextItem(text: "You can provide an optional description for your channel.", sectionId: self.section) + return ItemListTextItem(text: .plain("You can provide an optional description for your channel."), sectionId: self.section) } } } diff --git a/TelegramUI/FeaturedStickerPacksController.swift b/TelegramUI/FeaturedStickerPacksController.swift new file mode 100644 index 0000000000..97f013a44c --- /dev/null +++ b/TelegramUI/FeaturedStickerPacksController.swift @@ -0,0 +1,229 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private final class FeaturedStickerPacksControllerArguments { + let account: Account + + let openStickerPack: (StickerPackCollectionInfo) -> Void + let addPack: (StickerPackCollectionInfo) -> Void + + init(account: Account, openStickerPack: @escaping (StickerPackCollectionInfo) -> Void, addPack: @escaping (StickerPackCollectionInfo) -> Void) { + self.account = account + self.openStickerPack = openStickerPack + self.addPack = addPack + } +} + +private enum FeaturedStickerPacksSection: Int32 { + case stickers +} + +private enum FeaturedStickerPacksEntryId: Hashable { + case pack(ItemCollectionId) + + var hashValue: Int { + switch self { + case let .pack(id): + return id.hashValue + } + } + + static func ==(lhs: FeaturedStickerPacksEntryId, rhs: FeaturedStickerPacksEntryId) -> Bool { + switch lhs { + case let .pack(id): + if case .pack(id) = rhs { + return true + } else { + return false + } + } + } +} + +private enum FeaturedStickerPacksEntry: ItemListNodeEntry { + case pack(Int32, StickerPackCollectionInfo, Bool, StickerPackItem?, Int32, Bool) + + var section: ItemListSectionId { + switch self { + case .pack: + return FeaturedStickerPacksSection.stickers.rawValue + } + } + + var stableId: FeaturedStickerPacksEntryId { + switch self { + case let .pack(_, info, _, _, _, _): + return .pack(info.id) + } + } + + static func ==(lhs: FeaturedStickerPacksEntry, rhs: FeaturedStickerPacksEntry) -> Bool { + switch lhs { + case let .pack(lhsIndex, lhsInfo, lhsUnread, lhsTopItem, lhsCount, lhsInstalled): + if case let .pack(rhsIndex, rhsInfo, rhsUnread, rhsTopItem, rhsCount, rhsInstalled) = rhs { + if lhsIndex != rhsIndex { + return false + } + if lhsInfo != rhsInfo { + return false + } + if lhsUnread != rhsUnread { + return false + } + if lhsTopItem != rhsTopItem { + return false + } + if lhsCount != rhsCount { + return false + } + if lhsInstalled != rhsInstalled { + return false + } + return true + } else { + return false + } + } + } + + static func <(lhs: FeaturedStickerPacksEntry, rhs: FeaturedStickerPacksEntry) -> Bool { + switch lhs { + case let .pack(lhsIndex, _, _, _, _, _): + switch rhs { + case let .pack(rhsIndex, _, _, _, _, _): + return lhsIndex < rhsIndex + } + } + } + + func item(_ arguments: FeaturedStickerPacksControllerArguments) -> ListViewItem { + switch self { + case let .pack(_, info, unread, topItem, count, installed): + return ItemListStickerPackItem(account: arguments.account, packInfo: info, itemCount: count, topItem: topItem, unread: unread, control: .installation(installed: installed), editing: ItemListStickerPackItemEditing(editable: false, editing: false, revealed: false), enabled: true, sectionId: self.section, action: { _ in + arguments.openStickerPack(info) + }, setPackIdWithRevealedOptions: { _ in + }, addPack: { + arguments.addPack(info) + }, removePack: { _ in + }) + } + } +} + +private struct FeaturedStickerPacksControllerState: Equatable { + init() { + } + + static func ==(lhs: FeaturedStickerPacksControllerState, rhs: FeaturedStickerPacksControllerState) -> Bool { + return true + } +} + +private func featuredStickerPacksControllerEntries(state: FeaturedStickerPacksControllerState, view: CombinedView, featured: [FeaturedStickerPackItem], unreadPacks: [ItemCollectionId: Bool]) -> [FeaturedStickerPacksEntry] { + var entries: [FeaturedStickerPacksEntry] = [] + + if let stickerPacksView = view.views[.itemCollectionInfos(namespaces: [Namespaces.ItemCollection.CloudStickerPacks])] as? ItemCollectionInfosView, !featured.isEmpty { + if let packsEntries = stickerPacksView.entriesByNamespace[Namespaces.ItemCollection.CloudStickerPacks] { + var installedPacks = Set() + for entry in packsEntries { + installedPacks.insert(entry.id) + } + var index: Int32 = 0 + for item in featured { + var unread = false + if let value = unreadPacks[item.info.id] { + unread = value + } + entries.append(.pack(index, item.info, unread, item.topItems.first, item.info.count, installedPacks.contains(item.info.id))) + index += 1 + } + } + } + + return entries +} + +public func featuredStickerPacksController(account: Account) -> ViewController { + let statePromise = ValuePromise(FeaturedStickerPacksControllerState(), ignoreRepeated: true) + //let stateValue = Atomic(value: FeaturedStickerPacksControllerState()) + /*let updateState: ((FeaturedStickerPacksControllerState) -> FeaturedStickerPacksControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + }*/ + + var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)? + + let actionsDisposable = DisposableSet() + + let resolveDisposable = MetaDisposable() + actionsDisposable.add(resolveDisposable) + + let arguments = FeaturedStickerPacksControllerArguments(account: account, openStickerPack: { info in + presentControllerImpl?(StickerPackPreviewController(account: account, stickerPack: .id(id: info.id.id, accessHash: info.accessHash)), ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + }, addPack: { info in + presentControllerImpl?(StickerPackPreviewController(account: account, stickerPack: .id(id: info.id.id, accessHash: info.accessHash)), ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + }) + + let stickerPacks = Promise() + stickerPacks.set(account.postbox.combinedView(keys: [.itemCollectionInfos(namespaces: [Namespaces.ItemCollection.CloudStickerPacks])])) + + let featured = Promise<[FeaturedStickerPackItem]>() + featured.set(account.viewTracker.featuredStickerPacks()) + + var previousPackCount: Int? + var initialUnreadPacks: [ItemCollectionId: Bool] = [:] + + let signal = combineLatest(statePromise.get() |> deliverOnMainQueue, stickerPacks.get() |> deliverOnMainQueue, featured.get() |> deliverOnMainQueue) + |> map { state, view, featured -> (ItemListControllerState, (ItemListNodeState, FeaturedStickerPacksEntry.ItemGenerationArguments)) in + let packCount: Int? = featured.count + + for item in featured { + if initialUnreadPacks[item.info.id] == nil { + initialUnreadPacks[item.info.id] = item.unread + } + } + + let rightNavigationButton: ItemListNavigationButton? = nil + let previous = previousPackCount + previousPackCount = packCount + + let controllerState = ItemListControllerState(title: "Trending Stickers", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) + + let listState = ItemListNodeState(entries: featuredStickerPacksControllerEntries(state: state, view: view, featured: featured, unreadPacks: initialUnreadPacks), style: .blocks, animateChanges: false) + return (controllerState, (listState, arguments)) + } |> afterDisposed { + actionsDisposable.dispose() + } + + let controller = ItemListController(signal) + + var alreadyReadIds = Set() + + controller.visibleEntriesUpdated = { entries in + var unreadIds: [ItemCollectionId] = [] + for entry in entries { + switch entry { + case let .pack(_, info, unread, _, _, _): + if unread && !alreadyReadIds.contains(info.id) { + unreadIds.append(info.id) + } + } + } + if !unreadIds.isEmpty { + alreadyReadIds.formUnion(Set(unreadIds)) + + let _ = markFeaturedStickerPacksAsSeenInteractively(postbox: account.postbox, ids: unreadIds).start() + } + } + + controller.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + presentControllerImpl = { [weak controller] c, p in + if let controller = controller { + controller.present(c, in: .window, with: p) + } + } + + return controller +} diff --git a/TelegramUI/FetchCachedRepresentations.swift b/TelegramUI/FetchCachedRepresentations.swift index b29514fa47..8e36c039de 100644 --- a/TelegramUI/FetchCachedRepresentations.swift +++ b/TelegramUI/FetchCachedRepresentations.swift @@ -10,6 +10,8 @@ import UIKit public func fetchCachedResourceRepresentation(account: Account, resource: MediaResource, resourceData: MediaResourceData, representation: CachedMediaResourceRepresentation) -> Signal { if let representation = representation as? CachedStickerAJpegRepresentation { return fetchCachedStickerAJpegRepresentation(account: account, resource: resource, resourceData: resourceData, representation: representation) + } else if let representation = representation as? CachedScaledImageRepresentation { + return fetchCachedScaledImageRepresentation(account: account, resource: resource, resourceData: resourceData, representation: representation) } return .never() } @@ -88,3 +90,39 @@ private func fetchCachedStickerAJpegRepresentation(account: Account, resource: M return EmptyDisposable }) |> runOn(account.graphicsThreadPool) } + +private func fetchCachedScaledImageRepresentation(account: Account, resource: MediaResource, resourceData: MediaResourceData, representation: CachedScaledImageRepresentation) -> Signal { + return Signal({ subscriber in + if let data = try? Data(contentsOf: URL(fileURLWithPath: resourceData.path), options: [.mappedIfSafe]) { + if let image = UIImage(data: data) { + var randomId: Int64 = 0 + arc4random_buf(&randomId, 8) + let path = NSTemporaryDirectory() + "\(randomId)" + let url = URL(fileURLWithPath: path) + + let size = representation.size + + let colorImage = generateImage(size, contextGenerator: { size, context in + context.setBlendMode(.copy) + context.draw(image.cgImage!, in: CGRect(origin: CGPoint(), size: size)) + }, scale: 1.0)! + + if let colorDestination = CGImageDestinationCreateWithURL(url as CFURL, kUTTypeJPEG, 1, nil) { + CGImageDestinationSetProperties(colorDestination, nil) + + let colorQuality: Float = 0.5 + + let options = NSMutableDictionary() + options.setObject(colorQuality as NSNumber, forKey: kCGImageDestinationLossyCompressionQuality as NSString) + + CGImageDestinationAddImage(colorDestination, colorImage.cgImage!, options as CFDictionary) + if CGImageDestinationFinalize(colorDestination) { + subscriber.putNext(CachedMediaResourceRepresentationResult(temporaryPath: path)) + subscriber.putCompletion() + } + } + } + } + return EmptyDisposable + }) |> runOn(account.graphicsThreadPool) +} diff --git a/TelegramUI/GroupAdminsController.swift b/TelegramUI/GroupAdminsController.swift index 8828540f2f..d89c432043 100644 --- a/TelegramUI/GroupAdminsController.swift +++ b/TelegramUI/GroupAdminsController.swift @@ -148,7 +148,7 @@ private enum GroupAdminsEntry: ItemListNodeEntry { arguments.updateAllAreAdmins(updatedValue) }) case let .allAdminsInfo(text): - return ItemListTextItem(text: text, sectionId: self.section) + return ItemListTextItem(text: .plain(text), sectionId: self.section) case let .peerItem(_, peer, presence, toggled, enabled): return ItemListPeerItem(account: arguments.account, peer: peer, presence: presence, text: .presence, label: nil, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: false), switchValue: toggled, enabled: enabled, sectionId: self.section, action: nil, setPeerIdWithRevealedOptions: { _ in }, removePeer: { _ in }, toggleUpdated: { value in arguments.updatePeerIsAdmin(peer.id, value) diff --git a/TelegramUI/ImageNode.swift b/TelegramUI/ImageNode.swift index fb61e70725..2badb16777 100644 --- a/TelegramUI/ImageNode.swift +++ b/TelegramUI/ImageNode.swift @@ -24,14 +24,14 @@ public func ==(lhs: ImageCorner, rhs: ImageCorner) -> Bool { switch lhs { case let .Corner(lhsRadius): switch rhs { - case let .Corner(rhsRadius) where abs(lhsRadius - rhsRadius) < CGFloat(FLT_EPSILON): + case let .Corner(rhsRadius) where abs(lhsRadius - rhsRadius) < CGFloat.ulpOfOne: return true default: return false } case let .Tail(lhsRadius): switch rhs { - case let .Tail(rhsRadius) where abs(lhsRadius - rhsRadius) < CGFloat(FLT_EPSILON): + case let .Tail(rhsRadius) where abs(lhsRadius - rhsRadius) < CGFloat.ulpOfOne: return true default: return false diff --git a/TelegramUI/InstalledStickerPacksController.swift b/TelegramUI/InstalledStickerPacksController.swift new file mode 100644 index 0000000000..0c9d5a2a38 --- /dev/null +++ b/TelegramUI/InstalledStickerPacksController.swift @@ -0,0 +1,441 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private final class InstalledStickerPacksControllerArguments { + let account: Account + + let openStickerPack: (StickerPackCollectionInfo) -> Void + let setPackIdWithRevealedOptions: (ItemCollectionId?, ItemCollectionId?) -> Void + let removePack: (ItemCollectionId) -> Void + let openStickersBot: () -> Void + let openMasks: () -> Void + let openFeatured: () -> Void + let openArchived: () -> Void + + init(account: Account, openStickerPack: @escaping (StickerPackCollectionInfo) -> Void, setPackIdWithRevealedOptions: @escaping (ItemCollectionId?, ItemCollectionId?) -> Void, removePack: @escaping (ItemCollectionId) -> Void, openStickersBot: @escaping () -> Void, openMasks: @escaping () -> Void, openFeatured: @escaping () -> Void, openArchived: @escaping () -> Void) { + self.account = account + self.openStickerPack = openStickerPack + self.setPackIdWithRevealedOptions = setPackIdWithRevealedOptions + self.removePack = removePack + self.openStickersBot = openStickersBot + self.openMasks = openMasks + self.openFeatured = openFeatured + self.openArchived = openArchived + } +} + +private enum InstalledStickerPacksSection: Int32 { + case service + case stickers +} + +private enum InstalledStickerPacksEntryId: Hashable { + case index(Int32) + case pack(ItemCollectionId) + + var hashValue: Int { + switch self { + case let .index(index): + return index.hashValue + case let .pack(id): + return id.hashValue + } + } + + static func ==(lhs: InstalledStickerPacksEntryId, rhs: InstalledStickerPacksEntryId) -> Bool { + switch lhs { + case let .index(index): + if case .index(index) = rhs { + return true + } else { + return false + } + case let .pack(id): + if case .pack(id) = rhs { + return true + } else { + return false + } + } + } +} + +private enum InstalledStickerPacksEntry: ItemListNodeEntry { + case trending(Int32) + case archived + case masks + case packsTitle(String) + case pack(Int32, StickerPackCollectionInfo, StickerPackItem?, Int32, Bool, ItemListStickerPackItemEditing) + case packsInfo(String) + + var section: ItemListSectionId { + switch self { + case .trending, .masks, .archived: + return InstalledStickerPacksSection.service.rawValue + case .packsTitle, .pack, .packsInfo: + return InstalledStickerPacksSection.stickers.rawValue + } + } + + var stableId: InstalledStickerPacksEntryId { + switch self { + case .trending: + return .index(0) + case .archived: + return .index(1) + case .masks: + return .index(2) + case .packsTitle: + return .index(3) + case let .pack(_, info, _, _, _, _): + return .pack(info.id) + case .packsInfo: + return .index(4) + } + } + + static func ==(lhs: InstalledStickerPacksEntry, rhs: InstalledStickerPacksEntry) -> Bool { + switch lhs { + case let .trending(count): + if case .trending(count) = rhs { + return true + } else { + return false + } + case .masks, .archived: + return lhs.stableId == rhs.stableId + case let .packsTitle(text): + if case .packsTitle(text) = rhs { + return true + } else { + return false + } + case let .pack(lhsIndex, lhsInfo, lhsTopItem, lhsCount, lhsEnabled, lhsEditing): + if case let .pack(rhsIndex, rhsInfo, rhsTopItem, rhsCount, rhsEnabled, rhsEditing) = rhs { + if lhsIndex != rhsIndex { + return false + } + if lhsInfo != rhsInfo { + return false + } + if lhsTopItem != rhsTopItem { + return false + } + if lhsCount != rhsCount { + return false + } + if lhsEnabled != rhsEnabled { + return false + } + if lhsEditing != rhsEditing { + return false + } + return true + } else { + return false + } + case let .packsInfo(text): + if case .packsInfo(text) = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: InstalledStickerPacksEntry, rhs: InstalledStickerPacksEntry) -> Bool { + switch lhs { + case .trending: + switch rhs { + case .trending: + return false + default: + return true + } + case .archived: + switch rhs { + case .trending, .archived: + return false + default: + return true + } + case .masks: + switch rhs { + case .trending, .archived, .masks: + return false + default: + return true + } + case .packsTitle: + switch rhs { + case .trending, .masks, .archived, .packsTitle: + return false + default: + return true + } + case let .pack(lhsIndex, _, _, _, _, _): + switch rhs { + case let .pack(rhsIndex, _, _, _, _, _): + return lhsIndex < rhsIndex + case .packsInfo: + return true + default: + return false + } + case .packsInfo: + switch rhs { + case .packsInfo: + return false + default: + return false + } + } + } + + func item(_ arguments: InstalledStickerPacksControllerArguments) -> ListViewItem { + switch self { + case let .trending(count): + return ItemListDisclosureItem(title: "Trending", label: count == 0 ? "" : "\(count)", sectionId: self.section, style: .blocks, action: { + arguments.openFeatured() + }) + case .masks: + return ItemListDisclosureItem(title: "Masks", label: "", sectionId: self.section, style: .blocks, action: { + arguments.openMasks() + }) + case .archived: + return ItemListDisclosureItem(title: "Archived", label: "", sectionId: self.section, style: .blocks, action: { + arguments.openArchived() + }) + case let .packsTitle(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + case let .pack(_, info, topItem, count, enabled, editing): + return ItemListStickerPackItem(account: arguments.account, packInfo: info, itemCount: count, topItem: topItem, unread: false, control: .none, editing: editing, enabled: enabled, sectionId: self.section, action: { _ in + arguments.openStickerPack(info) + }, setPackIdWithRevealedOptions: { current, previous in + arguments.setPackIdWithRevealedOptions(current, previous) + }, addPack: { + }, removePack: { + arguments.removePack(info.id) + }) + case let .packsInfo(text): + return ItemListTextItem(text: .markdown(text), sectionId: self.section, linkAction: { _ in + arguments.openStickersBot() + }) + } + } +} + +private struct InstalledStickerPacksControllerState: Equatable { + let editing: Bool + let packIdWithRevealedOptions: ItemCollectionId? + + init() { + self.editing = false + self.packIdWithRevealedOptions = nil + } + + init(editing: Bool, packIdWithRevealedOptions: ItemCollectionId?) { + self.editing = editing + self.packIdWithRevealedOptions = packIdWithRevealedOptions + } + + static func ==(lhs: InstalledStickerPacksControllerState, rhs: InstalledStickerPacksControllerState) -> Bool { + if lhs.editing != rhs.editing { + return false + } + if lhs.packIdWithRevealedOptions != rhs.packIdWithRevealedOptions { + return false + } + + return true + } + + func withUpdatedEditing(_ editing: Bool) -> InstalledStickerPacksControllerState { + return InstalledStickerPacksControllerState(editing: editing, packIdWithRevealedOptions: self.packIdWithRevealedOptions) + } + + func withUpdatedPackIdWithRevealedOptions(_ packIdWithRevealedOptions: ItemCollectionId?) -> InstalledStickerPacksControllerState { + return InstalledStickerPacksControllerState(editing: self.editing, packIdWithRevealedOptions: packIdWithRevealedOptions) + } +} + +private func namespaceForMode(_ mode: InstalledStickerPacksControllerMode) -> ItemCollectionId.Namespace { + switch mode { + case .general: + return Namespaces.ItemCollection.CloudStickerPacks + case .masks: + return Namespaces.ItemCollection.CloudMaskPacks + } +} + +private func installedStickerPacksControllerEntries(state: InstalledStickerPacksControllerState, mode: InstalledStickerPacksControllerMode, view: CombinedView, featured: [FeaturedStickerPackItem]) -> [InstalledStickerPacksEntry] { + var entries: [InstalledStickerPacksEntry] = [] + + switch mode { + case .general: + if featured.count != 0 { + var unreadCount: Int32 = 0 + for item in featured { + if item.unread { + unreadCount += 1 + } + } + entries.append(.trending(unreadCount)) + } + entries.append(.archived) + entries.append(.masks) + entries.append(.packsTitle("STICKER SETS")) + case .masks: + break + } + + if let stickerPacksView = view.views[.itemCollectionInfos(namespaces: [namespaceForMode(mode)])] as? ItemCollectionInfosView { + if let packsEntries = stickerPacksView.entriesByNamespace[namespaceForMode(mode)] { + var index: Int32 = 0 + for entry in packsEntries { + if let info = entry.info as? StickerPackCollectionInfo { + entries.append(.pack(index, info, entry.firstItem as? StickerPackItem, info.count == 0 ? entry.count : info.count, true, ItemListStickerPackItemEditing(editable: true, editing: state.editing, revealed: state.packIdWithRevealedOptions == entry.id))) + index += 1 + } + } + } + } + + switch mode { + case .general: + entries.append(.packsInfo("Artists are welcome to add their own sticker sets using our [@stickers]() bot.\n\nTap on a sticker to view and add the whole set.")) + case .masks: + entries.append(.packsInfo("You can add masks to photos and videos you send. To do this, open the photo editor before sending a photo or video.")) + } + + return entries +} + +public enum InstalledStickerPacksControllerMode { + case general + case masks +} + +public func installedStickerPacksController(account: Account, mode: InstalledStickerPacksControllerMode) -> ViewController { + let statePromise = ValuePromise(InstalledStickerPacksControllerState(), ignoreRepeated: true) + let stateValue = Atomic(value: InstalledStickerPacksControllerState()) + let updateState: ((InstalledStickerPacksControllerState) -> InstalledStickerPacksControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)? + var pushControllerImpl: ((ViewController) -> Void)? + var navigateToChatControllerImpl: ((PeerId) -> Void)? + + let actionsDisposable = DisposableSet() + + let resolveDisposable = MetaDisposable() + actionsDisposable.add(resolveDisposable) + + let arguments = InstalledStickerPacksControllerArguments(account: account, openStickerPack: { info in + presentControllerImpl?(StickerPackPreviewController(account: account, stickerPack: .id(id: info.id.id, accessHash: info.accessHash)), ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + }, setPackIdWithRevealedOptions: { packId, fromPackId in + updateState { state in + if (packId == nil && fromPackId == state.packIdWithRevealedOptions) || (packId != nil && fromPackId == nil) { + return state.withUpdatedPackIdWithRevealedOptions(packId) + } else { + return state + } + } + }, removePack: { id in + let controller = ActionSheetController() + let dismissAction: () -> Void = { [weak controller] in + controller?.dismissAnimated() + } + controller.setItemGroups([ + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "Remove", color: .destructive, action: { + dismissAction() + let _ = removeStickerPackInteractively(postbox: account.postbox, id: id).start() + }) + ]), + ActionSheetItemGroup(items: [ActionSheetButtonItem(title: "Cancel", action: { dismissAction() })]) + ]) + presentControllerImpl?(controller, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + }, openStickersBot: { + resolveDisposable.set((resolvePeerByName(account: account, name: "stickers") |> deliverOnMainQueue).start(next: { peerId in + if let peerId = peerId { + navigateToChatControllerImpl?(peerId) + } + })) + }, openMasks: { + pushControllerImpl?(installedStickerPacksController(account: account, mode: .masks)) + }, openFeatured: { + pushControllerImpl?(featuredStickerPacksController(account: account)) + }, openArchived: { + pushControllerImpl?(archivedStickerPacksController(account: account)) + }) + let stickerPacks = Promise() + stickerPacks.set(account.postbox.combinedView(keys: [.itemCollectionInfos(namespaces: [namespaceForMode(mode)])])) + + let featured = Promise<[FeaturedStickerPackItem]>() + switch mode { + case .general: + featured.set(account.viewTracker.featuredStickerPacks()) + case .masks: + featured.set(.single([])) + } + + var previousPackCount: Int? + + let signal = combineLatest(statePromise.get() |> deliverOnMainQueue, stickerPacks.get() |> deliverOnMainQueue, featured.get() |> deliverOnMainQueue) + |> map { state, view, featured -> (ItemListControllerState, (ItemListNodeState, InstalledStickerPacksEntry.ItemGenerationArguments)) in + var packCount: Int? = nil + if let stickerPacksView = view.views[.itemCollectionInfos(namespaces: [namespaceForMode(mode)])] as? ItemCollectionInfosView, let entries = stickerPacksView.entriesByNamespace[namespaceForMode(mode)] { + packCount = entries.count + } + + var rightNavigationButton: ItemListNavigationButton? + if let packCount = packCount, packCount != 0 { + if state.editing { + rightNavigationButton = ItemListNavigationButton(title: "Done", style: .bold, enabled: true, action: { + updateState { + $0.withUpdatedEditing(false) + } + }) + } else { + rightNavigationButton = ItemListNavigationButton(title: "Edit", style: .regular, enabled: true, action: { + updateState { + $0.withUpdatedEditing(true) + } + }) + } + } + + let previous = previousPackCount + previousPackCount = packCount + + let controllerState = ItemListControllerState(title: mode == .general ? "Stickers" : "Masks", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) + + let listState = ItemListNodeState(entries: installedStickerPacksControllerEntries(state: state, mode: mode, view: view, featured: featured), style: .blocks, animateChanges: previous != nil && packCount != nil && (previous! != 0 && previous! >= packCount! - 10)) + return (controllerState, (listState, arguments)) + } |> afterDisposed { + actionsDisposable.dispose() + } + + let controller = ItemListController(signal) + controller.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + presentControllerImpl = { [weak controller] c, p in + if let controller = controller { + controller.present(c, in: .window, with: p) + } + } + pushControllerImpl = { [weak controller] c in + (controller?.navigationController as? NavigationController)?.pushViewController(c) + } + navigateToChatControllerImpl = { [weak controller] peerId in + if let controller = controller, let navigationController = controller.navigationController as? NavigationController { + navigateToChatController(navigationController: navigationController, account: account, peerId: peerId) + } + } + + return controller +} diff --git a/TelegramUI/ItemListCheckboxItem.swift b/TelegramUI/ItemListCheckboxItem.swift index facbb14694..28371ba08e 100644 --- a/TelegramUI/ItemListCheckboxItem.swift +++ b/TelegramUI/ItemListCheckboxItem.swift @@ -3,16 +3,6 @@ import Display import AsyncDisplayKit import SwiftSignalKit -/* - - - - Created with Sketch. - - - - */ - private let checkIcon = generateImage(CGSize(width: 14.0, height: 11.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setStrokeColor(UIColor(0x007ee5).cgColor) diff --git a/TelegramUI/ItemListController.swift b/TelegramUI/ItemListController.swift index a22fe3596b..ba05eae955 100644 --- a/TelegramUI/ItemListController.swift +++ b/TelegramUI/ItemListController.swift @@ -47,10 +47,25 @@ final class ItemListController: ViewController { private var didPlayPresentationAnimation = false + private let _ready = Promise() + override var ready: Promise { + return self._ready + } + + var visibleEntriesUpdated: ((ItemListNodeVisibleEntries) -> Void)? { + didSet { + (self.displayNode as! ItemListNode).visibleEntriesUpdated = self.visibleEntriesUpdated + } + } + init(_ state: Signal<(ItemListControllerState, (ItemListNodeState, Entry.ItemGenerationArguments)), NoError>) { self.state = state super.init() + + self.scrollToTop = { [weak self] in + (self?.displayNode as! ItemListNode).scrollToTop() + } } required init(coder aDecoder: NSCoder) { @@ -109,6 +124,7 @@ final class ItemListController: ViewController { } self.displayNode = displayNode super.displayNodeDidLoad() + self._ready.set((self.displayNode as! ItemListNode).ready) } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { @@ -138,7 +154,7 @@ final class ItemListController: ViewController { } } - func dismiss() { + override func dismiss() { (self.displayNode as! ItemListNode).animateOut() } diff --git a/TelegramUI/ItemListControllerNode.swift b/TelegramUI/ItemListControllerNode.swift index 6c4ba1711b..b4ae8ecaaa 100644 --- a/TelegramUI/ItemListControllerNode.swift +++ b/TelegramUI/ItemListControllerNode.swift @@ -34,13 +34,15 @@ enum ItemListStyle { case blocks } -private struct ItemListNodeTransition { +private struct ItemListNodeTransition { let entries: ItemListNodeEntryTransition let updateStyle: ItemListStyle? let emptyStateItem: ItemListControllerEmptyStateItem? + let focusItemTag: ItemListItemTag? let firstTime: Bool let animated: Bool let animateAlpha: Bool + let mergedEntries: [Entry] } struct ItemListNodeState { @@ -48,12 +50,36 @@ struct ItemListNodeState { let style: ItemListStyle let emptyStateItem: ItemListControllerEmptyStateItem? let animateChanges: Bool + let focusItemTag: ItemListItemTag? - init(entries: [Entry], style: ItemListStyle, emptyStateItem: ItemListControllerEmptyStateItem? = nil, animateChanges: Bool = true) { + init(entries: [Entry], style: ItemListStyle, focusItemTag: ItemListItemTag? = nil, emptyStateItem: ItemListControllerEmptyStateItem? = nil, animateChanges: Bool = true) { self.entries = entries self.style = style self.emptyStateItem = emptyStateItem self.animateChanges = animateChanges + self.focusItemTag = focusItemTag + } +} + +private final class ItemListNodeOpaqueState { + let mergedEntries: [Entry] + + init(mergedEntries: [Entry]) { + self.mergedEntries = mergedEntries + } +} + +final class ItemListNodeVisibleEntries: Sequence { + let iterate: () -> Entry? + + init(iterate: @escaping () -> Entry?) { + self.iterate = iterate + } + + func makeIterator() -> AnyIterator { + return AnyIterator { () -> Entry? in + return self.iterate() + } } } @@ -70,11 +96,13 @@ final class ItemListNode: ASDisplayNode { private let transitionDisposable = MetaDisposable() - private var enqueuedTransitions: [ItemListNodeTransition] = [] + private var enqueuedTransitions: [ItemListNodeTransition] = [] private var validLayout: (ContainerViewLayout, CGFloat)? var dismiss: (() -> Void)? + var visibleEntriesUpdated: ((ItemListNodeVisibleEntries) -> Void)? + init(state: Signal<(ItemListNodeState, Entry.ItemGenerationArguments), NoError>) { self.listNode = ListView() @@ -86,8 +114,27 @@ final class ItemListNode: ASDisplayNode { self.backgroundColor = UIColor(0xefeff4) + self.listNode.displayedItemRangeChanged = { [weak self] displayedRange, opaqueTransactionState in + if let strongSelf = self, let visibleEntriesUpdated = strongSelf.visibleEntriesUpdated, let mergedEntries = (opaqueTransactionState as? ItemListNodeOpaqueState)?.mergedEntries { + if let visible = displayedRange.visibleRange { + let indexRange = (visible.firstIndex, visible.lastIndex) + + var index = indexRange.0 + let iterator = ItemListNodeVisibleEntries(iterate: { + var item: Entry? + if index <= indexRange.1 { + item = mergedEntries[index] + } + index += 1 + return item + }) + visibleEntriesUpdated(iterator) + } + } + } + let previousState = Atomic?>(value: nil) - self.transitionDisposable.set(((state |> map { state, arguments -> ItemListNodeTransition in + self.transitionDisposable.set(((state |> map { state, arguments -> ItemListNodeTransition in assert(state.entries == state.entries.sorted()) let previous = previousState.swap(state) let transition = preparedItemListNodeEntryTransition(from: previous?.entries ?? [], to: state.entries, arguments: arguments) @@ -95,7 +142,7 @@ final class ItemListNode: ASDisplayNode { if previous?.style != state.style { updatedStyle = state.style } - return ItemListNodeTransition(entries: transition, updateStyle: updatedStyle, emptyStateItem: state.emptyStateItem, firstTime: previous == nil, animated: previous != nil && state.animateChanges, animateAlpha: previous != nil && !state.animateChanges) + return ItemListNodeTransition(entries: transition, updateStyle: updatedStyle, emptyStateItem: state.emptyStateItem, focusItemTag: state.focusItemTag, firstTime: previous == nil, animated: previous != nil && state.animateChanges, animateAlpha: previous != nil && !state.animateChanges, mergedEntries: state.entries) }) |> deliverOnMainQueue).start(next: { [weak self] transition in if let strongSelf = self { strongSelf.enqueueTransition(transition) @@ -161,7 +208,7 @@ final class ItemListNode: ASDisplayNode { } } - private func enqueueTransition(_ transition: ItemListNodeTransition) { + private func enqueueTransition(_ transition: ItemListNodeTransition) { self.enqueuedTransitions.append(transition) if self.validLayout != nil { self.dequeueTransitions() @@ -191,12 +238,23 @@ final class ItemListNode: ASDisplayNode { options.insert(.PreferSynchronousDrawing) options.insert(.AnimateAlpha) } - self.listNode.transaction(deleteIndices: transition.entries.deletions, insertIndicesAndItems: transition.entries.insertions, updateIndicesAndItems: transition.entries.updates, options: options, updateOpaqueState: nil, completion: { [weak self] _ in + let focusItemTag = transition.focusItemTag + self.listNode.transaction(deleteIndices: transition.entries.deletions, insertIndicesAndItems: transition.entries.insertions, updateIndicesAndItems: transition.entries.updates, options: options, updateOpaqueState: ItemListNodeOpaqueState(mergedEntries: transition.mergedEntries), completion: { [weak self] _ in if let strongSelf = self { if !strongSelf.didSetReady { strongSelf.didSetReady = true strongSelf._ready.set(true) } + + if let focusItemTag = focusItemTag { + strongSelf.listNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ItemListItemNode, let itemTag = itemNode.tag, itemTag.isEqual(to: focusItemTag) { + if let focusableNode = itemNode as? ItemListItemFocusableNode { + focusableNode.focus() + } + } + } + } } }) var updateEmptyStateItem = false @@ -226,4 +284,8 @@ final class ItemListNode: ASDisplayNode { } } } + + func scrollToTop() { + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .Top, animated: true, curve: .Default, directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + } } diff --git a/TelegramUI/ItemListEditableItem.swift b/TelegramUI/ItemListEditableItem.swift index 1a22091d84..ad732bf6a7 100644 --- a/TelegramUI/ItemListEditableItem.swift +++ b/TelegramUI/ItemListEditableItem.swift @@ -129,6 +129,9 @@ class ItemListRevealOptionsItemNode: ListViewItemNode { self.revealOptionsInteractivelyOpened() } self.updateRevealOffsetInternal(offset: translation.x, transition: .immediate) + if self.revealNode == nil { + self.revealOptionsInteractivelyClosed() + } case .ended, .cancelled: if let recognizer = self.recognizer, let revealNode = self.revealNode { let velocity = recognizer.velocity(in: self.view) diff --git a/TelegramUI/ItemListItem.swift b/TelegramUI/ItemListItem.swift index 4b381bb4ae..c2a09126f0 100644 --- a/TelegramUI/ItemListItem.swift +++ b/TelegramUI/ItemListItem.swift @@ -1,7 +1,12 @@ import Display +protocol ItemListItemTag { + func isEqual(to other: ItemListItemTag) -> Bool +} + protocol ItemListItem { var sectionId: ItemListSectionId { get } + var tag: ItemListItemTag? { get } var isAlwaysPlain: Bool { get } } @@ -9,6 +14,18 @@ extension ItemListItem { var isAlwaysPlain: Bool { return false } + + var tag: ItemListItemTag? { + return nil + } +} + +protocol ItemListItemNode { + var tag: ItemListItemTag? { get } +} + +protocol ItemListItemFocusableNode { + func focus() } enum ItemListNeighbor { diff --git a/TelegramUI/ItemListSingleLineInputItem.swift b/TelegramUI/ItemListSingleLineInputItem.swift index 4f553065a2..bf16d0630e 100644 --- a/TelegramUI/ItemListSingleLineInputItem.swift +++ b/TelegramUI/ItemListSingleLineInputItem.swift @@ -3,18 +3,31 @@ import Display import AsyncDisplayKit import SwiftSignalKit +enum ItemListSingleLineInputItemType { + case regular + case password + case email + case number +} + class ItemListSingleLineInputItem: ListViewItem, ItemListItem { let title: NSAttributedString let text: String let placeholder: String + let type: ItemListSingleLineInputItemType + let spacing: CGFloat let sectionId: ItemListSectionId let action: () -> Void let textUpdated: (String) -> Void + let tag: ItemListItemTag? - init(title: NSAttributedString, text: String, placeholder: String, sectionId: ItemListSectionId, textUpdated: @escaping (String) -> Void, action: @escaping () -> Void) { + init(title: NSAttributedString, text: String, placeholder: String, type: ItemListSingleLineInputItemType = .regular, spacing: CGFloat = 0.0, tag: ItemListItemTag? = nil, sectionId: ItemListSectionId, textUpdated: @escaping (String) -> Void, action: @escaping () -> Void) { self.title = title self.text = text self.placeholder = placeholder + self.type = type + self.spacing = spacing + self.tag = tag self.sectionId = sectionId self.textUpdated = textUpdated self.action = action @@ -54,7 +67,7 @@ class ItemListSingleLineInputItem: ListViewItem, ItemListItem { private let titleFont = Font.regular(17.0) -class ItemListSingleLineInputItemNode: ListViewItemNode, UITextFieldDelegate { +class ItemListSingleLineInputItemNode: ListViewItemNode, UITextFieldDelegate, ItemListItemNode, ItemListItemFocusableNode { private let backgroundNode: ASDisplayNode private let topStripeNode: ASDisplayNode private let bottomStripeNode: ASDisplayNode @@ -64,6 +77,10 @@ class ItemListSingleLineInputItemNode: ListViewItemNode, UITextFieldDelegate { private var item: ItemListSingleLineInputItem? + var tag: ItemListItemTag? { + return self.item?.tag + } + init() { self.backgroundNode = ASDisplayNode() self.backgroundNode.isLayerBacked = true @@ -127,6 +144,39 @@ class ItemListSingleLineInputItemNode: ListViewItemNode, UITextFieldDelegate { let _ = titleApply() strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: floor((layout.contentSize.height - titleLayout.size.height) / 2.0)), size: titleLayout.size) + let secureEntry: Bool + let capitalizationType: UITextAutocapitalizationType + let keyboardType: UIKeyboardType + + switch item.type { + case .regular: + secureEntry = false + capitalizationType = .sentences + keyboardType = UIKeyboardType.default + case .email: + secureEntry = false + capitalizationType = .none + keyboardType = UIKeyboardType.emailAddress + case .password: + secureEntry = true + capitalizationType = .none + keyboardType = UIKeyboardType.default + case .number: + secureEntry = true + capitalizationType = .none + keyboardType = UIKeyboardType.numberPad + } + + if strongSelf.textNode.textField.isSecureTextEntry != secureEntry { + strongSelf.textNode.textField.isSecureTextEntry = secureEntry + } + if strongSelf.textNode.textField.keyboardType != keyboardType { + strongSelf.textNode.textField.keyboardType = keyboardType + } + if strongSelf.textNode.textField.autocapitalizationType != capitalizationType { + strongSelf.textNode.textField.autocapitalizationType = capitalizationType + } + if let currentText = strongSelf.textNode.textField.text { if currentText != item.text { strongSelf.textNode.textField.text = item.text @@ -135,7 +185,7 @@ class ItemListSingleLineInputItemNode: ListViewItemNode, UITextFieldDelegate { strongSelf.textNode.textField.text = item.text } - strongSelf.textNode.frame = CGRect(origin: CGPoint(x: leftInset + titleLayout.size.width, y: floor((layout.contentSize.height - 40.0) / 2.0)), size: CGSize(width: max(1.0, width - (leftInset + titleLayout.size.width)), height: 40.0)) + strongSelf.textNode.frame = CGRect(origin: CGPoint(x: leftInset + titleLayout.size.width + item.spacing, y: floor((layout.contentSize.height - 40.0) / 2.0)), size: CGSize(width: max(1.0, width - (leftInset + titleLayout.size.width + item.spacing)), height: 40.0)) if strongSelf.backgroundNode.supernode == nil { strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) @@ -188,4 +238,10 @@ class ItemListSingleLineInputItemNode: ListViewItemNode, UITextFieldDelegate { } } } + + func focus() { + if !self.textNode.textField.isFirstResponder { + self.textNode.textField.becomeFirstResponder() + } + } } diff --git a/TelegramUI/ItemListStickerPackItem.swift b/TelegramUI/ItemListStickerPackItem.swift new file mode 100644 index 0000000000..4cd0b0f9b1 --- /dev/null +++ b/TelegramUI/ItemListStickerPackItem.swift @@ -0,0 +1,579 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore + +struct ItemListStickerPackItemEditing: Equatable { + let editable: Bool + let editing: Bool + let revealed: Bool + + static func ==(lhs: ItemListStickerPackItemEditing, rhs: ItemListStickerPackItemEditing) -> Bool { + if lhs.editable != rhs.editable { + return false + } + if lhs.editing != rhs.editing { + return false + } + if lhs.revealed != rhs.revealed { + return false + } + return true + } +} + +enum ItemListStickerPackItemControl: Equatable { + case none + case installation(installed: Bool) + + static func ==(lhs: ItemListStickerPackItemControl, rhs: ItemListStickerPackItemControl) -> Bool { + switch lhs { + case .none: + if case .none = rhs { + return true + } else { + return false + } + case let .installation(installed): + if case .installation(installed) = rhs { + return true + } else { + return false + } + } + } +} + +final class ItemListStickerPackItem: ListViewItem, ItemListItem { + let account: Account + let packInfo: StickerPackCollectionInfo + let itemCount: Int32 + let topItem: StickerPackItem? + let unread: Bool + let control: ItemListStickerPackItemControl + let editing: ItemListStickerPackItemEditing + let enabled: Bool + let sectionId: ItemListSectionId + let action: (() -> Void)? + let setPackIdWithRevealedOptions: (ItemCollectionId?, ItemCollectionId?) -> Void + let addPack: () -> Void + let removePack: () -> Void + + init(account: Account, packInfo: StickerPackCollectionInfo, itemCount: Int32, topItem: StickerPackItem?, unread: Bool, control: ItemListStickerPackItemControl, editing: ItemListStickerPackItemEditing, enabled: Bool, sectionId: ItemListSectionId, action: (() -> Void)?, setPackIdWithRevealedOptions: @escaping (ItemCollectionId?, ItemCollectionId?) -> Void, addPack: @escaping () -> Void, removePack: @escaping () -> Void) { + self.account = account + self.packInfo = packInfo + self.itemCount = itemCount + self.topItem = topItem + self.unread = unread + self.control = control + self.editing = editing + self.enabled = enabled + self.sectionId = sectionId + self.action = action + self.setPackIdWithRevealedOptions = setPackIdWithRevealedOptions + self.addPack = addPack + self.removePack = removePack + } + + func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { + async { + let node = ItemListStickerPackItemNode() + let (layout, apply) = node.asyncLayout()(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + completion(node, { + return (nil, { apply(false) }) + }) + } + } + + 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? ItemListStickerPackItemNode { + Queue.mainQueue().async { + let makeLayout = node.asyncLayout() + + var animated = true + if case .None = animation { + animated = false + } + + async { + let (layout, apply) = makeLayout(self, width, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + Queue.mainQueue().async { + completion(layout, { + apply(animated) + }) + } + } + } + } + } + + var selectable: Bool = true + + func selected(listView: ListView){ + listView.clearHighlightAnimated(true) + self.action?() + } +} + +private let titleFont = Font.bold(15.0) +private let statusFont = Font.regular(14.0) + +private func stringForStickerCount(_ count: Int32) -> String { + if count == 1 { + return "1 sticker" + } else { + return "\(count) stickers" + } +} + +private let plusIcon = generateImage(CGSize(width: 18.0, height: 18.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor(0x007ee5).cgColor) + let lineWidth = min(1.5, UIScreenPixel * 4.0) + context.fill(CGRect(x: floorToScreenPixels((18.0 - lineWidth) / 2.0), y: 0.0, width: lineWidth, height: 18.0)) + context.fill(CGRect(x: 0.0, y: floorToScreenPixels((18.0 - lineWidth) / 2.0), width: 18.0, height: lineWidth)) +}) + +private let checkIcon = generateImage(CGSize(width: 14.0, height: 11.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setStrokeColor(UIColor(0x8d8c9d).cgColor) + context.setLineWidth(2.0) + context.move(to: CGPoint(x: 12.0, y: 1.0)) + context.addLine(to: CGPoint(x: 4.16482734, y: 9.0)) + context.addLine(to: CGPoint(x: 1.0, y: 5.81145833)) + context.strokePath() +}) + +private let unreadIcon = generateFilledCircleImage(diameter: 6.0, color: UIColor(0x007ee5)) + +class ItemListStickerPackItemNode: ItemListRevealOptionsItemNode { + private let backgroundNode: ASDisplayNode + private let topStripeNode: ASDisplayNode + private let bottomStripeNode: ASDisplayNode + private let highlightedBackgroundNode: ASDisplayNode + private var disabledOverlayNode: ASDisplayNode? + + fileprivate let imageNode: TransformImageNode + private let unreadNode: ASImageNode + private let titleNode: TextNode + private let statusNode: TextNode + private let installationActionImageNode: ASImageNode + private let installationActionNode: HighlightableButtonNode + + private var layoutParams: (ItemListStickerPackItem, CGFloat, ItemListNeighbors)? + + private var editableControlNode: ItemListEditableControlNode? + + private let fetchDisposable = MetaDisposable() + + override var canBeSelected: Bool { + if self.editableControlNode != nil || self.disabledOverlayNode != nil { + return false + } + if let item = self.layoutParams?.0, item.action != nil { + return super.canBeSelected + } else { + return false + } + } + + init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isLayerBacked = true + self.backgroundNode.backgroundColor = .white + + self.topStripeNode = ASDisplayNode() + self.topStripeNode.backgroundColor = UIColor(0xc8c7cc) + self.topStripeNode.isLayerBacked = true + + self.bottomStripeNode = ASDisplayNode() + self.bottomStripeNode.backgroundColor = UIColor(0xc8c7cc) + self.bottomStripeNode.isLayerBacked = true + + self.imageNode = TransformImageNode() + self.imageNode.isLayerBacked = true + + self.titleNode = TextNode() + self.titleNode.isLayerBacked = true + self.titleNode.contentMode = .left + self.titleNode.contentsScale = UIScreen.main.scale + + self.statusNode = TextNode() + self.statusNode.isLayerBacked = true + self.statusNode.contentMode = .left + self.statusNode.contentsScale = UIScreen.main.scale + + self.unreadNode = ASImageNode() + self.unreadNode.isLayerBacked = true + self.unreadNode.displaysAsynchronously = false + self.unreadNode.displayWithoutProcessing = true + + self.installationActionImageNode = ASImageNode() + self.installationActionImageNode.displaysAsynchronously = false + self.installationActionImageNode.displayWithoutProcessing = true + self.installationActionImageNode.isLayerBacked = true + self.installationActionNode = HighlightableButtonNode() + + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.backgroundColor = UIColor(0xd9d9d9) + self.highlightedBackgroundNode.isLayerBacked = true + + super.init(layerBacked: false, dynamicBounce: false, rotated: false) + + self.addSubnode(self.imageNode) + self.addSubnode(self.titleNode) + self.addSubnode(self.statusNode) + self.addSubnode(self.unreadNode) + self.addSubnode(self.installationActionImageNode) + self.addSubnode(self.installationActionNode) + + self.installationActionNode.addTarget(self, action: #selector(self.installationActionPressed), forControlEvents: .touchUpInside) + } + + deinit { + self.fetchDisposable.dispose() + } + + func asyncLayout() -> (_ item: ItemListStickerPackItem, _ width: CGFloat, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, (Bool) -> Void) { + let makeImageLayout = self.imageNode.asyncLayout() + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + let makeStatusLayout = TextNode.asyncLayout(self.statusNode) + let editableControlLayout = ItemListEditableControlNode.asyncLayout(self.editableControlNode) + let previousFile = self.layoutParams?.0.topItem?.file + + var currentDisabledOverlayNode = self.disabledOverlayNode + + return { item, width, neighbors in + var titleAttributedString: NSAttributedString? + var statusAttributedString: NSAttributedString? + + let packRevealOptions: [ItemListRevealOption] + if item.editing.editable && item.enabled { + packRevealOptions = [ItemListRevealOption(key: 0, title: "Remove", icon: nil, color: UIColor(0xff3824))] + } else { + packRevealOptions = [] + } + + var rightInset: CGFloat = 0.0 + + var installationActionImage: UIImage? + switch item.control { + case .none: + break + case let .installation(installed): + rightInset += 50.0 + if installed { + installationActionImage = checkIcon + } else { + installationActionImage = plusIcon + } + } + + var unreadImage: UIImage? + if item.unread { + unreadImage = unreadIcon + } + + titleAttributedString = NSAttributedString(string: item.packInfo.title, font: titleFont, textColor: UIColor.black) + statusAttributedString = NSAttributedString(string: stringForStickerCount(item.itemCount), font: statusFont, textColor: UIColor(0x808080)) + + let leftInset: CGFloat = 65.0 + + var editableControlSizeAndApply: (CGSize, () -> ItemListEditableControlNode)? + + let editingOffset: CGFloat + if item.editing.editing { + let sizeAndApply = editableControlLayout(59.0) + editableControlSizeAndApply = sizeAndApply + editingOffset = sizeAndApply.0.width + } else { + editingOffset = 0.0 + } + + let (titleLayout, titleApply) = makeTitleLayout(titleAttributedString, nil, 1, .end, CGSize(width: width - leftInset - 8.0 - editingOffset - rightInset - 10.0, height: CGFloat.greatestFiniteMagnitude), nil) + let (statusLayout, statusApply) = makeStatusLayout(statusAttributedString, nil, 1, .end, CGSize(width: width - leftInset - 8.0 - editingOffset - rightInset, height: CGFloat.greatestFiniteMagnitude), nil) + + let insets = itemListNeighborsGroupedInsets(neighbors) + let contentSize = CGSize(width: width, height: 59.0) + let separatorHeight = UIScreenPixel + + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) + let layoutSize = layout.size + + if !item.enabled { + if currentDisabledOverlayNode == nil { + currentDisabledOverlayNode = ASDisplayNode() + currentDisabledOverlayNode?.backgroundColor = UIColor(white: 1.0, alpha: 0.5) + } + } else { + currentDisabledOverlayNode = nil + } + + let file = item.topItem?.file + var fileUpdated = false + if let file = file, let previousFile = previousFile { + fileUpdated = !file.isEqual(previousFile) + } else if (file != nil) != (previousFile != nil) { + fileUpdated = true + } + + var imageApply: (() -> Void)? + if let file = file, let dimensions = file.dimensions { + let imageBoundingSize = CGSize(width: 34.0, height: 34.0) + let fileImageSize = dimensions.aspectFitted(imageBoundingSize) + imageApply = makeImageLayout(TransformImageArguments(corners: ImageCorners(), imageSize: fileImageSize, boundingSize: imageBoundingSize, intrinsicInsets: UIEdgeInsets())) + } + + var updatedImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? + var updatedFetchSignal: Signal? + if fileUpdated { + if let file = file { + updatedImageSignal = chatMessageSticker(account: item.account, file: file, small: false) + updatedFetchSignal = item.account.postbox.mediaBox.fetchedResource(file.resource) + } else { + updatedImageSignal = .single({ _ in return nil }) + updatedFetchSignal = .complete() + } + } + + return (layout, { [weak self] animated in + if let strongSelf = self { + strongSelf.layoutParams = (item, width, neighbors) + + let revealOffset = strongSelf.revealOffset + + let transition: ContainedViewLayoutTransition + if animated { + transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) + } else { + transition = .immediate + } + + if let currentDisabledOverlayNode = currentDisabledOverlayNode { + if currentDisabledOverlayNode != strongSelf.disabledOverlayNode { + strongSelf.disabledOverlayNode = currentDisabledOverlayNode + strongSelf.addSubnode(currentDisabledOverlayNode) + currentDisabledOverlayNode.alpha = 0.0 + transition.updateAlpha(node: currentDisabledOverlayNode, alpha: 1.0) + currentDisabledOverlayNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: layout.contentSize.width, height: layout.contentSize.height - separatorHeight)) + } else { + transition.updateFrame(node: currentDisabledOverlayNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: layout.contentSize.width, height: layout.contentSize.height - separatorHeight))) + } + } else if let disabledOverlayNode = strongSelf.disabledOverlayNode { + transition.updateAlpha(node: disabledOverlayNode, alpha: 0.0, completion: { [weak disabledOverlayNode] _ in + disabledOverlayNode?.removeFromSupernode() + }) + strongSelf.disabledOverlayNode = nil + } + + if let editableControlSizeAndApply = editableControlSizeAndApply { + if strongSelf.editableControlNode == nil { + let editableControlNode = editableControlSizeAndApply.1() + editableControlNode.tapped = { + if let strongSelf = self { + strongSelf.setRevealOptionsOpened(true, animated: true) + strongSelf.revealOptionsInteractivelyOpened() + } + } + strongSelf.editableControlNode = editableControlNode + strongSelf.insertSubnode(editableControlNode, aboveSubnode: strongSelf.imageNode) + let editableControlFrame = CGRect(origin: CGPoint(x: revealOffset, y: 0.0), size: editableControlSizeAndApply.0) + editableControlNode.frame = editableControlFrame + transition.animatePosition(node: editableControlNode, from: CGPoint(x: editableControlFrame.midX - editableControlFrame.size.width, y: editableControlFrame.midY)) + editableControlNode.alpha = 0.0 + transition.updateAlpha(node: editableControlNode, alpha: 1.0) + } + strongSelf.editableControlNode?.isHidden = !item.editing.editable + } else if let editableControlNode = strongSelf.editableControlNode { + var editableControlFrame = editableControlNode.frame + editableControlFrame.origin.x = -editableControlFrame.size.width + strongSelf.editableControlNode = nil + transition.updateAlpha(node: editableControlNode, alpha: 0.0) + transition.updateFrame(node: editableControlNode, frame: editableControlFrame, completion: { [weak editableControlNode] _ in + editableControlNode?.removeFromSupernode() + }) + } + + imageApply?() + + let _ = titleApply() + let _ = statusApply() + + let installationActionFrame = CGRect(origin: CGPoint(x: width - 50.0, y: 0.0), size: CGSize(width: 50.0, height: layout.contentSize.height)) + strongSelf.installationActionNode.frame = installationActionFrame + + switch item.control { + case .none: + strongSelf.installationActionNode.isHidden = true + strongSelf.installationActionImageNode.isHidden = true + case let .installation(installed): + strongSelf.installationActionImageNode.isHidden = false + strongSelf.installationActionNode.isHidden = false + strongSelf.installationActionNode.isUserInteractionEnabled = !installed + strongSelf.installationActionNode.setImage(installationActionImage, for: []) + if let image = installationActionImage { + let imageSize = image.size + strongSelf.installationActionImageNode.frame = CGRect(origin: CGPoint(x: installationActionFrame.minX + floor((installationActionFrame.size.width - imageSize.width) / 2.0), y: installationActionFrame.minY + floor((installationActionFrame.size.height - imageSize.height) / 2.0)), size: imageSize) + } + } + + if strongSelf.backgroundNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) + } + if strongSelf.topStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1) + } + if strongSelf.bottomStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) + } + switch neighbors.top { + case .sameSection(false): + strongSelf.topStripeNode.isHidden = true + default: + strongSelf.topStripeNode.isHidden = false + } + let bottomStripeInset: CGFloat + let bottomStripeOffset: CGFloat + switch neighbors.bottom { + case .sameSection(false): + bottomStripeInset = leftInset + editingOffset + bottomStripeOffset = -separatorHeight + default: + bottomStripeInset = 0.0 + bottomStripeOffset = 0.0 + } + strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) + transition.updateFrame(node: strongSelf.topStripeNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight))) + transition.updateFrame(node: strongSelf.bottomStripeNode, frame: CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight))) + + if let unreadImage = unreadImage { + strongSelf.unreadNode.image = unreadImage + strongSelf.unreadNode.isHidden = false + strongSelf.unreadNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 16.0), size: unreadImage.size) + } else { + strongSelf.unreadNode.isHidden = true + } + + transition.updateFrame(node: strongSelf.titleNode, frame: CGRect(origin: CGPoint(x: (strongSelf.unreadNode.isHidden ? 0.0 : 10.0) + leftInset + revealOffset + editingOffset, y: 11.0), size: titleLayout.size)) + transition.updateFrame(node: strongSelf.statusNode, frame: CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: 32.0), size: statusLayout.size)) + + transition.updateFrame(node: strongSelf.imageNode, frame: CGRect(origin: CGPoint(x: revealOffset + editingOffset + 15.0, y: 12.0), size: CGSize(width: 34.0, height: 34.0))) + + if let updatedImageSignal = updatedImageSignal { + strongSelf.imageNode.setSignal(account: item.account, signal: updatedImageSignal) + } + + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: width, height: 59.0 + UIScreenPixel + UIScreenPixel)) + + strongSelf.setRevealOptions(packRevealOptions) + strongSelf.setRevealOptionsOpened(item.editing.revealed, animated: animated) + + if let updatedFetchSignal = updatedFetchSignal { + strongSelf.fetchDisposable.set(updatedFetchSignal.start()) + } + } + }) + } + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + if highlighted { + self.highlightedBackgroundNode.alpha = 1.0 + if self.highlightedBackgroundNode.supernode == nil { + var anchorNode: ASDisplayNode? + if self.bottomStripeNode.supernode != nil { + anchorNode = self.bottomStripeNode + } else if self.topStripeNode.supernode != nil { + anchorNode = self.topStripeNode + } else if self.backgroundNode.supernode != nil { + anchorNode = self.backgroundNode + } + if let anchorNode = anchorNode { + self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: anchorNode) + } else { + self.addSubnode(self.highlightedBackgroundNode) + } + } + } else { + if self.highlightedBackgroundNode.supernode != nil { + if animated { + self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in + if let strongSelf = self { + if completed { + strongSelf.highlightedBackgroundNode.removeFromSupernode() + } + } + }) + self.highlightedBackgroundNode.alpha = 0.0 + } else { + self.highlightedBackgroundNode.removeFromSupernode() + } + } + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + } + + override func updateRevealOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) { + super.updateRevealOffset(offset: offset, transition: transition) + + let leftInset: CGFloat = 65.0 + let width = self.bounds.size.width + + let editingOffset: CGFloat + if let editableControlNode = self.editableControlNode { + editingOffset = editableControlNode.bounds.size.width + var editableControlFrame = editableControlNode.frame + editableControlFrame.origin.x = offset + transition.updateFrame(node: editableControlNode, frame: editableControlFrame) + } else { + editingOffset = 0.0 + } + + transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: self.titleNode.frame.minY), size: self.titleNode.bounds.size)) + transition.updateFrame(node: self.statusNode, frame: CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: self.statusNode.frame.minY), size: self.statusNode.bounds.size)) + + transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(x: revealOffset + editingOffset + 15.0, y: self.imageNode.frame.minY), size: CGSize(width: 34.0, height: 34.0))) + } + + override func revealOptionsInteractivelyOpened() { + if let (item, _, _) = self.layoutParams { + item.setPackIdWithRevealedOptions(item.packInfo.id, nil) + } + } + + override func revealOptionsInteractivelyClosed() { + if let (item, _, _) = self.layoutParams { + item.setPackIdWithRevealedOptions(nil, item.packInfo.id) + } + } + + override func revealOptionSelected(_ option: ItemListRevealOption) { + self.setRevealOptionsOpened(false, animated: true) + self.revealOptionsInteractivelyClosed() + + if let (item, _, _) = self.layoutParams { + item.removePack() + } + } + + @objc func installationActionPressed() { + if let (item, _, _) = self.layoutParams { + item.addPack() + } + } +} diff --git a/TelegramUI/ItemListTextItem.swift b/TelegramUI/ItemListTextItem.swift index 79a3f5f0e5..2caa54618a 100644 --- a/TelegramUI/ItemListTextItem.swift +++ b/TelegramUI/ItemListTextItem.swift @@ -3,15 +3,26 @@ import Display import AsyncDisplayKit import SwiftSignalKit +enum ItemListTextItemText { + case plain(String) + case markdown(String) +} + +enum ItemListTextItemLinkAction { + case tap(String) +} + class ItemListTextItem: ListViewItem, ItemListItem { - let text: String + let text: ItemListTextItemText let sectionId: ItemListSectionId + let linkAction: ((ItemListTextItemLinkAction) -> Void)? let isAlwaysPlain: Bool = true - init(text: String, sectionId: ItemListSectionId) { + init(text: ItemListTextItemText, sectionId: ItemListSectionId, linkAction: ((ItemListTextItemLinkAction) -> Void)? = nil) { self.text = text self.sectionId = sectionId + self.linkAction = linkAction } func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { @@ -54,6 +65,8 @@ private let titleFont = Font.regular(14.0) class ItemListTextItemNode: ListViewItemNode { private let titleNode: TextNode + private var item: ItemListTextItem? + init() { self.titleNode = TextNode() self.titleNode.isLayerBacked = true @@ -65,6 +78,16 @@ class ItemListTextItemNode: ListViewItemNode { self.addSubnode(self.titleNode) } + override func didLoad() { + super.didLoad() + + let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) + recognizer.tapActionAtPoint = { _ in + return .waitForSingleTap + } + self.view.addGestureRecognizer(recognizer) + } + func asyncLayout() -> (_ item: ItemListTextItem, _ width: CGFloat, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) { let makeTitleLayout = TextNode.asyncLayout(self.titleNode) @@ -72,18 +95,28 @@ class ItemListTextItemNode: ListViewItemNode { let leftInset: CGFloat = 15.0 let verticalInset: CGFloat = 7.0 - let (titleLayout, titleApply) = makeTitleLayout(NSAttributedString(string: item.text, font: titleFont, textColor: UIColor(0x6d6d72)), nil, 0, .end, CGSize(width: width - 20, height: CGFloat.greatestFiniteMagnitude), nil) + let attributedText: NSAttributedString + switch item.text { + case let .plain(text): + attributedText = NSAttributedString(string: text, font: titleFont, textColor: UIColor(0x6d6d72)) + case let .markdown(text): + attributedText = parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: titleFont, textColor: UIColor(0x6d6d72)), link: MarkdownAttributeSet(font: titleFont, textColor: UIColor(0x007ee5)), linkAttribute: { contents in + return (TextNode.UrlAttribute, contents) + })) + } + let (titleLayout, titleApply) = makeTitleLayout(attributedText, nil, 0, .end, CGSize(width: width - 20, height: CGFloat.greatestFiniteMagnitude), nil) let contentSize: CGSize - let insets: UIEdgeInsets contentSize = CGSize(width: width, height: titleLayout.size.height + verticalInset + verticalInset) - insets = itemListNeighborsPlainInsets(neighbors) + let insets = itemListNeighborsGroupedInsets(neighbors) let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) return (layout, { [weak self] in if let strongSelf = self { + strongSelf.item = item + let _ = titleApply() strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: verticalInset), size: titleLayout.size) @@ -99,4 +132,26 @@ class ItemListTextItemNode: ListViewItemNode { override func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) } + + @objc func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { + switch recognizer.state { + case .ended: + if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation { + switch gesture { + case .tap: + let titleFrame = self.titleNode.frame + if let item = self.item, titleFrame.contains(location) { + let attributes = self.titleNode.attributesAtPoint(CGPoint(x: location.x - titleFrame.minX, y: location.y - titleFrame.minY)) + if let url = attributes[TextNode.UrlAttribute] as? String { + item.linkAction?(.tap(url)) + } + } + default: + break + } + } + default: + break + } + } } diff --git a/TelegramUI/LegacyController.swift b/TelegramUI/LegacyController.swift index 3fbebdc72b..e881ee6683 100644 --- a/TelegramUI/LegacyController.swift +++ b/TelegramUI/LegacyController.swift @@ -162,7 +162,7 @@ class LegacyController: ViewController { self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationHeight, transition: transition) } - public func dismiss() { + override open func dismiss() { switch self.presentation { case .modal: self.controllerNode.animateModalOut { [weak self] in diff --git a/TelegramUI/Markdown.swift b/TelegramUI/Markdown.swift new file mode 100644 index 0000000000..52e16b0512 --- /dev/null +++ b/TelegramUI/Markdown.swift @@ -0,0 +1,98 @@ +import Foundation +import Display + +private let controlStartCharactersSet = CharacterSet(charactersIn: "[") +private let controlCharactersSet = CharacterSet(charactersIn: "[]()*_-\\") + +final class MarkdownAttributeSet { + let font: UIFont + let textColor: UIColor + + init(font: UIFont, textColor: UIColor) { + self.font = font + self.textColor = textColor + } +} + +final class MarkdownAttributes { + let body: MarkdownAttributeSet + let link: MarkdownAttributeSet + let linkAttribute: (String) -> (String, Any)? + + init(body: MarkdownAttributeSet, link: MarkdownAttributeSet, linkAttribute: @escaping (String) -> (String, Any)?) { + self.body = body + self.link = link + self.linkAttribute = linkAttribute + } +} + +func escapedPlaintextForMarkdown(_ string: String) -> String { + let nsString = string as NSString + var remainingRange = NSMakeRange(0, nsString.length) + let result = NSMutableString() + while true { + let range = nsString.rangeOfCharacter(from: controlCharactersSet, options: [], range: remainingRange) + if range.location != NSNotFound { + result.append("\\") + result.append(nsString.substring(with: NSMakeRange(range.location, range.length))) + remainingRange = NSMakeRange(range.location + range.length, remainingRange.location + remainingRange.length - (range.location + range.length)) + } else { + result.append(nsString.substring(with: NSMakeRange(remainingRange.location, remainingRange.length))) + break + } + } + return result as String +} + +func parseMarkdownIntoAttributedString(_ string: String, attributes: MarkdownAttributes) -> NSAttributedString { + let nsString = string as NSString + let result = NSMutableAttributedString() + var remainingRange = NSMakeRange(0, nsString.length) + + let bodyAttributes: [String: Any] = [NSFontAttributeName: attributes.body.font, NSForegroundColorAttributeName: attributes.body.textColor] + + while true { + let range = nsString.rangeOfCharacter(from: controlStartCharactersSet, options: [], range: remainingRange) + if range.location != NSNotFound { + if range.location != remainingRange.location { + result.append(NSAttributedString(string: nsString.substring(with: NSMakeRange(remainingRange.location, range.location - remainingRange.location)), attributes: bodyAttributes)) + remainingRange = NSMakeRange(range.location, remainingRange.location + remainingRange.length - range.location) + } + + let character = nsString.character(at: range.location) + if character == UInt16(("[" as UnicodeScalar).value) { + remainingRange = NSMakeRange(range.location + range.length, remainingRange.location + remainingRange.length - (range.location + range.length)) + if let (parsedLinkText, parsedLinkContents) = parseLink(string: nsString, remainingRange: &remainingRange) { + var linkAttributes: [String: Any] = [NSFontAttributeName: attributes.link.font, NSForegroundColorAttributeName: attributes.link.textColor] + if let (attributeName, attributeValue) = attributes.linkAttribute(parsedLinkContents) { + linkAttributes[attributeName] = attributeValue + } + result.append(NSAttributedString(string: parsedLinkText, attributes: linkAttributes)) + } + } + } else { + if remainingRange.length != 0 { + result.append(NSAttributedString(string: nsString.substring(with: NSMakeRange(remainingRange.location, remainingRange.length)), attributes: bodyAttributes)) + } + break + } + } + return result +} + +private func parseLink(string: NSString, remainingRange: inout NSRange) -> (text: String, contents: String)? { + var localRemainingRange = remainingRange + let closingSquareBraceRange = string.range(of: "]", options: [], range: localRemainingRange) + if closingSquareBraceRange.location != NSNotFound { + localRemainingRange = NSMakeRange(closingSquareBraceRange.location + closingSquareBraceRange.length, remainingRange.location + remainingRange.length - (closingSquareBraceRange.location + closingSquareBraceRange.length)) + let openingRoundBraceRange = string.range(of: "(", options: [], range: localRemainingRange) + let closingRoundBraceRange = string.range(of: ")", options: [], range: localRemainingRange) + if openingRoundBraceRange.location == closingSquareBraceRange.location + closingSquareBraceRange.length && closingRoundBraceRange.location != NSNotFound && openingRoundBraceRange.location < closingRoundBraceRange.location { + let linkText = string.substring(with: NSMakeRange(remainingRange.location, closingSquareBraceRange.location - remainingRange.location)) + let linkContents = string.substring(with: NSMakeRange(openingRoundBraceRange.location + openingRoundBraceRange.length, closingRoundBraceRange.location - (openingRoundBraceRange.location + openingRoundBraceRange.length))) + remainingRange = NSMakeRange(closingRoundBraceRange.location + closingRoundBraceRange.length, remainingRange.location + remainingRange.length - (closingRoundBraceRange.location + closingRoundBraceRange.length)) + return (linkText, linkContents) + } + } + return nil +} diff --git a/TelegramUI/NavigateToChatController.swift b/TelegramUI/NavigateToChatController.swift new file mode 100644 index 0000000000..0a8b099bf6 --- /dev/null +++ b/TelegramUI/NavigateToChatController.swift @@ -0,0 +1,19 @@ +import Foundation +import Display +import TelegramCore +import Postbox + +func navigateToChatController(navigationController: NavigationController, account: Account, peerId: PeerId) { + var found = false + for controller in navigationController.viewControllers { + if let controller = controller as? ChatController, controller.peerId == peerId { + navigationController.popToViewController(controller, animated: true) + found = true + break + } + } + + if !found { + navigationController.pushViewController(ChatController(account: account, peerId: peerId)) + } +} diff --git a/TelegramUI/NotificationsAndSounds.swift b/TelegramUI/NotificationsAndSounds.swift index a1753ae194..b8fd281112 100644 --- a/TelegramUI/NotificationsAndSounds.swift +++ b/TelegramUI/NotificationsAndSounds.swift @@ -246,7 +246,7 @@ private enum NotificationsAndSoundsEntry: ItemListNodeEntry { })) }) case .messageNotice: - return ItemListTextItem(text: "You can set custom notifications for specific users on their info page.", sectionId: self.section) + return ItemListTextItem(text: .plain("You can set custom notifications for specific users on their info page."), sectionId: self.section) case .groupHeader: return ItemListSectionHeaderItem(text: "GROUP NOTIFICATIONS", sectionId: self.section) case let .groupAlerts(value): @@ -268,7 +268,7 @@ private enum NotificationsAndSoundsEntry: ItemListNodeEntry { })) }) case .groupNotice: - return ItemListTextItem(text: "You can set custom notifications for specific groups on their info page.", sectionId: self.section) + return ItemListTextItem(text: .plain("You can set custom notifications for specific groups on their info page."), sectionId: self.section) case .inAppHeader: return ItemListSectionHeaderItem(text: "IN-APP NOTIFICATIONS", sectionId: self.section) case let .inAppSounds(value): @@ -288,7 +288,7 @@ private enum NotificationsAndSoundsEntry: ItemListNodeEntry { arguments.resetNotifications() }) case .resetNotice: - return ItemListTextItem(text: "Undo all custom notification settings for all your contacts and groups.", sectionId: self.section) + return ItemListTextItem(text: .plain("Undo all custom notification settings for all your contacts and groups."), sectionId: self.section) } } } diff --git a/TelegramUI/PeerInfoController.swift b/TelegramUI/PeerInfoController.swift index 28e8033a69..b7b6374385 100644 --- a/TelegramUI/PeerInfoController.swift +++ b/TelegramUI/PeerInfoController.swift @@ -13,311 +13,8 @@ func peerInfoController(account: Account, peer: Peer) -> ViewController? { } else { return channelInfoController(account: account, peerId: peer.id) } - } else if let _ = peer as? TelegramUser { + } else if peer is TelegramUser || peer is TelegramSecretChat { return userInfoController(account: account, peerId: peer.id) } return nil } - - - -final class PeerInfoControllerInteraction { - let updateState: ((PeerInfoState?) -> PeerInfoState?) -> Void - let openSharedMedia: () -> Void - let changeNotificationMuteSettings: () -> Void - let openPeerInfo: (PeerId) -> Void - - init(updateState: @escaping ((PeerInfoState?) -> PeerInfoState?) -> Void, openSharedMedia: @escaping () -> Void, changeNotificationMuteSettings: @escaping () -> Void, openPeerInfo: @escaping (PeerId) -> Void) { - self.updateState = updateState - self.openSharedMedia = openSharedMedia - self.changeNotificationMuteSettings = changeNotificationMuteSettings - self.openPeerInfo = openPeerInfo - } -} - -private struct PeerInfoSortableStableId: Hashable { - let id: PeerInfoEntryStableId - - static func ==(lhs: PeerInfoSortableStableId, rhs: PeerInfoSortableStableId) -> Bool { - return lhs.id.isEqual(to: rhs.id) - } - - var hashValue: Int { - return self.id.hashValue - } -} - -private struct PeerInfoSortableEntry: Identifiable, Comparable { - let entry: PeerInfoEntry - - var stableId: PeerInfoSortableStableId { - return PeerInfoSortableStableId(id: self.entry.stableId) - } - - static func ==(lhs: PeerInfoSortableEntry, rhs: PeerInfoSortableEntry) -> Bool { - return lhs.entry.isEqual(to: rhs.entry) - } - - static func <(lhs: PeerInfoSortableEntry, rhs: PeerInfoSortableEntry) -> Bool { - return lhs.entry.isOrderedBefore(rhs.entry) - } -} - -private struct PeerInfoEntryTransition { - let deletions: [ListViewDeleteItem] - let insertions: [ListViewInsertItem] - let updates: [ListViewUpdateItem] -} - -private func preparedPeerInfoEntryTransition(account: Account, from fromEntries: [PeerInfoSortableEntry], to toEntries: [PeerInfoSortableEntry], interaction: PeerInfoControllerInteraction) -> PeerInfoEntryTransition { - 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.entry.item(account: account, interaction: interaction), directionHint: nil) } - let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.entry.item(account: account, interaction: interaction), directionHint: nil) } - - return PeerInfoEntryTransition(deletions: deletions, insertions: insertions, updates: updates) -} - -private struct PeerInfoEquatableState: Equatable { - let state: PeerInfoState? - - static func ==(lhs: PeerInfoEquatableState, rhs: PeerInfoEquatableState) -> Bool { - if let lhsState = lhs.state, let rhsState = rhs.state { - return lhsState.isEqual(to: rhsState) - } else if (lhs.state != nil) != (rhs.state != nil) { - return false - } else { - return true - } - } -} - -public final class PeerInfoController: ListController { - private let account: Account - private let peerId: PeerId - - private var _ready = Promise() - override public var ready: Promise { - return self._ready - } - private var didSetReady = false - - private let transitionDisposable = MetaDisposable() - private let changeSettingsDisposable = MetaDisposable() - private let additionalInfoDisposable = MetaDisposable() - - private var currentListStyle: ItemListStyle = .plain - - private var state = PeerInfoEquatableState(state: nil) { - didSet { - self.statePromise.set(.single(self.state)) - } - } - private var statePromise = Promise(PeerInfoEquatableState(state: nil)) - - private var leftNavigationButtonItem: UIBarButtonItem? - private var leftNavigationButton: PeerInfoNavigationButton? - private var rightNavigationButtonItem: UIBarButtonItem? - private var rightNavigationButton: PeerInfoNavigationButton? - - public init(account: Account, peerId: PeerId) { - self.account = account - self.peerId = peerId - - super.init() - - self.title = "Info" - } - - required public init(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - self.transitionDisposable.dispose() - self.changeSettingsDisposable.dispose() - self.additionalInfoDisposable.dispose() - } - - override public func displayNodeDidLoad() { - super.displayNodeDidLoad() - - let interaction = PeerInfoControllerInteraction(updateState: { [weak self] f in - if let strongSelf = self { - strongSelf.state = PeerInfoEquatableState(state: f(strongSelf.state.state)) - } - }, openSharedMedia: { [weak self] in - if let strongSelf = self { - if let controller = peerSharedMediaController(account: strongSelf.account, peerId: strongSelf.peerId) { - (strongSelf.navigationController as? NavigationController)?.pushViewController(controller) - } - } - }, changeNotificationMuteSettings: { [weak self] in - if let strongSelf = self { - let controller = ActionSheetController() - let dismissAction: () -> Void = { [weak controller] in - controller?.dismissAnimated() - } - let notificationAction: (Int32) -> Void = { [weak strongSelf] muteUntil in - if let strongSelf = strongSelf { - let muteState: PeerMuteState - if muteUntil <= 0 { - muteState = .unmuted - } else if muteUntil == Int32.max { - muteState = .muted(until: Int32.max) - } else { - muteState = .muted(until: Int32(Date().timeIntervalSince1970) + muteUntil) - } - strongSelf.changeSettingsDisposable.set(changePeerNotificationSettings(account: strongSelf.account, peerId: strongSelf.peerId, settings: TelegramPeerNotificationSettings(muteState: muteState, messageSound: PeerMessageSound.bundledModern(id: 0))).start()) - } - } - controller.setItemGroups([ - ActionSheetItemGroup(items: [ - ActionSheetButtonItem(title: "Enable", action: { - dismissAction() - notificationAction(0) - }), - ActionSheetButtonItem(title: "Mute for 1 hour", action: { - dismissAction() - notificationAction(1 * 60 * 60) - }), - ActionSheetButtonItem(title: "Mute for 8 hours", action: { - dismissAction() - notificationAction(8 * 60 * 60) - }), - ActionSheetButtonItem(title: "Mute for 2 days", action: { - dismissAction() - notificationAction(2 * 24 * 60 * 60) - }), - ActionSheetButtonItem(title: "Disable", action: { - dismissAction() - notificationAction(Int32.max) - }) - ]), - ActionSheetItemGroup(items: [ActionSheetButtonItem(title: "Cancel", action: { dismissAction() })]) - ]) - strongSelf.present(controller, in: .window) - } - }, openPeerInfo: { [weak self] peerId in - if let strongSelf = self { - let controller = PeerInfoController(account: strongSelf.account, peerId: peerId) - (strongSelf.navigationController as? NavigationController)?.pushViewController(controller) - } - }) - - self.listDisplayNode.backgroundColor = UIColor.white - - let previousEntries = Atomic<[PeerInfoSortableEntry]?>(value: nil) - - let account = self.account - let transition = combineLatest(account.viewTracker.peerView(self.peerId), self.statePromise.get() - |> distinctUntilChanged) - |> map { view, state -> (PeerInfoEntryTransition, ItemListStyle, Bool, Bool, PeerInfoNavigationButton?, PeerInfoNavigationButton?) in - let infoEntries = peerInfoEntries(view: view, state: state.state) - let entries = infoEntries.entries.map { PeerInfoSortableEntry(entry: $0) } - assert(entries == entries.sorted()) - let previous = previousEntries.swap(entries) - let style: ItemListStyle - if let _ = view.peers[view.peerId] as? TelegramGroup { - style = .blocks - } else if let channel = view.peers[view.peerId] as? TelegramChannel, case .group = channel.info { - style = .blocks - } else { - style = .plain - } - let animated: Bool - if let previous = previous { - animated = (entries.count - previous.count) < 20 - } else { - animated = false - } - return (preparedPeerInfoEntryTransition(account: account, from: previous ?? [], to: entries, interaction: interaction), style, previous == nil, animated, infoEntries.leftNavigationButton, infoEntries.rightNavigationButton) - } - |> deliverOnMainQueue - - self.transitionDisposable.set(transition.start(next: { [weak self] (transition, style, firstTime, animated, leftButton, rightButton) in - if let strongSelf = self { - strongSelf.enqueueTransition(transition, style: style, firstTime: firstTime, animated: animated) - if let leftButton = leftButton { - if let leftNavigationButtonItem = strongSelf.leftNavigationButtonItem { - if leftNavigationButtonItem.title != leftButton.title { - strongSelf.leftNavigationButtonItem = UIBarButtonItem(title: leftButton.title, style: .plain, target: strongSelf, action: #selector(strongSelf.leftNavigationButtonPressed)) - strongSelf.navigationItem.setLeftBarButton(strongSelf.leftNavigationButtonItem, animated: false) - } - strongSelf.leftNavigationButton = leftButton - } else { - strongSelf.leftNavigationButton = leftButton - strongSelf.leftNavigationButtonItem = UIBarButtonItem(title: leftButton.title, style: .plain, target: strongSelf, action: #selector(strongSelf.leftNavigationButtonPressed)) - strongSelf.navigationItem.setLeftBarButton(strongSelf.leftNavigationButtonItem, animated: false) - } - } else if strongSelf.leftNavigationButtonItem != nil { - strongSelf.leftNavigationButtonItem = nil - strongSelf.leftNavigationButton = nil - strongSelf.navigationItem.setLeftBarButton(nil, animated: false) - } - - if let rightButton = rightButton { - if let rightNavigationButtonItem = strongSelf.rightNavigationButtonItem { - if rightNavigationButtonItem.title != rightButton.title { - strongSelf.rightNavigationButtonItem = UIBarButtonItem(title: rightButton.title, style: .plain, target: strongSelf, action: #selector(strongSelf.rightNavigationButtonPressed)) - strongSelf.navigationItem.setRightBarButton(strongSelf.rightNavigationButtonItem, animated: false) - } - strongSelf.rightNavigationButton = rightButton - } else { - strongSelf.rightNavigationButton = rightButton - strongSelf.rightNavigationButtonItem = UIBarButtonItem(title: rightButton.title, style: .plain, target: strongSelf, action: #selector(strongSelf.rightNavigationButtonPressed)) - strongSelf.navigationItem.setRightBarButton(strongSelf.rightNavigationButtonItem, animated: false) - } - } else if strongSelf.rightNavigationButtonItem != nil { - strongSelf.rightNavigationButtonItem = nil - strongSelf.rightNavigationButton = nil - strongSelf.navigationItem.setRightBarButton(nil, animated: false) - } - } - })) - - if self.peerId.namespace == Namespaces.Peer.CloudChannel { - self.additionalInfoDisposable.set(self.account.viewTracker.updatedCachedChannelParticipants(self.peerId, forceImmediateUpdate: true).start()) - } - } - - private func enqueueTransition(_ transition: PeerInfoEntryTransition, style: ItemListStyle, firstTime: Bool, animated: Bool) { - if self.currentListStyle != style { - self.currentListStyle = style - switch style { - case .plain: - self.listDisplayNode.backgroundColor = .white - case .blocks: - self.listDisplayNode.backgroundColor = UIColor(0xefeff4) - } - } - var options = ListViewDeleteAndInsertOptions() - if firstTime { - options.insert(.Synchronous) - options.insert(.LowLatency) - } else if animated { - options.insert(.AnimateInsertion) - } - self.listDisplayNode.listView.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateOpaqueState: nil, completion: { [weak self] _ in - if let strongSelf = self { - if !strongSelf.didSetReady { - strongSelf.didSetReady = true - strongSelf._ready.set(.single(true)) - } - } - }) - } - - @objc func leftNavigationButtonPressed() { - if let leftNavigationButton = self.leftNavigationButton { - self.state = PeerInfoEquatableState(state: leftNavigationButton.action(self.state.state)) - } - } - - @objc func rightNavigationButtonPressed() { - if let rightNavigationButton = self.rightNavigationButton { - self.state = PeerInfoEquatableState(state: rightNavigationButton.action(self.state.state)) - } - } -} diff --git a/TelegramUI/PeerInfoEntries.swift b/TelegramUI/PeerInfoEntries.swift index 2b408fe155..e69de29bb2 100644 --- a/TelegramUI/PeerInfoEntries.swift +++ b/TelegramUI/PeerInfoEntries.swift @@ -1,66 +0,0 @@ -import Foundation -import Postbox -import TelegramCore -import Display - -protocol PeerInfoEntryStableId { - func isEqual(to: PeerInfoEntryStableId) -> Bool - var hashValue: Int { get } -} - -struct IntPeerInfoEntryStableId: PeerInfoEntryStableId { - let value: Int - - func isEqual(to: PeerInfoEntryStableId) -> Bool { - if let to = to as? IntPeerInfoEntryStableId, to.value == self.value { - return true - } else { - return false - } - } - - var hashValue: Int { - return self.value.hashValue - } -} - -protocol PeerInfoEntry { - var section: ItemListSectionId { get } - var stableId: PeerInfoEntryStableId { get } - func isEqual(to: PeerInfoEntry) -> Bool - func isOrderedBefore(_ entry: PeerInfoEntry) -> Bool - func item(account: Account, interaction: PeerInfoControllerInteraction) -> ListViewItem -} - -struct PeerInfoNavigationButton { - let title: String - let action: (PeerInfoState?) -> PeerInfoState? -} - -protocol PeerInfoState { - func isEqual(to: PeerInfoState) -> Bool -} - -struct PeerInfoEntries { - let entries: [PeerInfoEntry] - let leftNavigationButton: PeerInfoNavigationButton? - let rightNavigationButton: PeerInfoNavigationButton? -} - -func peerInfoEntries(view: PeerView, state: PeerInfoState?) -> PeerInfoEntries { - /*if let user = view.peers[view.peerId] as? TelegramUser { - return userInfoEntries(view: view, state: state) - } else if let secretChat = view.peers[view.peerId] as? TelegramSecretChat { - return userInfoEntries(view: view, state: state) - } else if let channel = view.peers[view.peerId] as? TelegramChannel { - switch channel.info { - case .broadcast: - return channelBroadcastInfoEntries(view: view) - case .group: - return groupInfoEntries(view: view, state: state) - } - } else if let group = view.peers[view.peerId] as? TelegramGroup { - return groupInfoEntries(view: view, state: state) - }*/ - return PeerInfoEntries(entries: [], leftNavigationButton: nil, rightNavigationButton: nil) -} diff --git a/TelegramUI/PeerMediaCollectionController.swift b/TelegramUI/PeerMediaCollectionController.swift index e11e1c5c81..ed1775d01e 100644 --- a/TelegramUI/PeerMediaCollectionController.swift +++ b/TelegramUI/PeerMediaCollectionController.swift @@ -126,7 +126,7 @@ public class PeerMediaCollectionController: ViewController { } } } - }, openSecretMessagePreview: { _ in }, closeSecretMessagePreview: { }, openPeer: { [weak self] id, navigation in + }, openSecretMessagePreview: { _ in }, closeSecretMessagePreview: { }, openPeer: { [weak self] id, navigation, _ in if let strongSelf = self { if let id = id { (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: id, messageId: nil)) diff --git a/TelegramUI/PeerSelectionController.swift b/TelegramUI/PeerSelectionController.swift index dd5e91ab22..29cf6fd4c5 100644 --- a/TelegramUI/PeerSelectionController.swift +++ b/TelegramUI/PeerSelectionController.swift @@ -139,7 +139,7 @@ public final class PeerSelectionController: ViewController { } } - public func dismiss() { + override open func dismiss() { self.peerSelectionNode.animateOut() } } diff --git a/TelegramUI/PhoneInputNode.swift b/TelegramUI/PhoneInputNode.swift index dd8ccbbb99..7f304ee35a 100644 --- a/TelegramUI/PhoneInputNode.swift +++ b/TelegramUI/PhoneInputNode.swift @@ -107,15 +107,19 @@ final class PhoneInputNode: ASDisplayNode, UITextFieldDelegate { private let phoneFormatter = InteractivePhoneFormatter() - override init() { + private let fontSize: CGFloat + + init(fontSize: CGFloat = 20.0) { + self.fontSize = fontSize + self.countryCodeField = TextFieldNode() - self.countryCodeField.textField.font = Font.regular(20.0) + self.countryCodeField.textField.font = Font.regular(fontSize) self.countryCodeField.textField.textAlignment = .center self.countryCodeField.textField.keyboardType = .numberPad self.countryCodeField.textField.returnKeyType = .next self.numberField = TextFieldNode() - self.numberField.textField.font = Font.regular(20.0) + self.numberField.textField.font = Font.regular(fontSize) self.numberField.textField.keyboardType = .numberPad super.init() diff --git a/TelegramUI/PhotoResources.swift b/TelegramUI/PhotoResources.swift index d0d0de4eb3..b815ca76af 100644 --- a/TelegramUI/PhotoResources.swift +++ b/TelegramUI/PhotoResources.swift @@ -333,25 +333,24 @@ private func tailContext(_ tail: Tail) -> DrawingContext { private func addCorners(_ context: DrawingContext, arguments: TransformImageArguments) { let corners = arguments.corners let drawingRect = arguments.drawingRect - - if case let .Corner(radius) = corners.topLeft, radius > CGFloat(FLT_EPSILON) { + if case let .Corner(radius) = corners.topLeft, radius > CGFloat.ulpOfOne { let corner = cornerContext(.TopLeft(Int(radius))) context.blt(corner, at: CGPoint(x: drawingRect.minX, y: drawingRect.minY)) } - if case let .Corner(radius) = corners.topRight, radius > CGFloat(FLT_EPSILON) { + if case let .Corner(radius) = corners.topRight, radius > CGFloat.ulpOfOne { let corner = cornerContext(.TopRight(Int(radius))) context.blt(corner, at: CGPoint(x: drawingRect.maxX - radius, y: drawingRect.minY)) } switch corners.bottomLeft { case let .Corner(radius): - if radius > CGFloat(FLT_EPSILON) { + if radius > CGFloat.ulpOfOne { let corner = cornerContext(.BottomLeft(Int(radius))) context.blt(corner, at: CGPoint(x: drawingRect.minX, y: drawingRect.maxY - radius)) } case let .Tail(radius): - if radius > CGFloat(FLT_EPSILON) { + if radius > CGFloat.ulpOfOne { let tail = tailContext(.BottomLeft(Int(radius))) let color = context.colorAt(CGPoint(x: drawingRect.minX, y: drawingRect.maxY - 1.0)) context.withContext { c in @@ -365,12 +364,12 @@ private func addCorners(_ context: DrawingContext, arguments: TransformImageArgu switch corners.bottomRight { case let .Corner(radius): - if radius > CGFloat(FLT_EPSILON) { + if radius > CGFloat.ulpOfOne { let corner = cornerContext(.BottomRight(Int(radius))) context.blt(corner, at: CGPoint(x: drawingRect.maxX - radius, y: drawingRect.maxY - radius)) } case let .Tail(radius): - if radius > CGFloat(FLT_EPSILON) { + if radius > CGFloat.ulpOfOne { let tail = tailContext(.BottomRight(Int(radius))) let color = context.colorAt(CGPoint(x: drawingRect.maxX - 1.0, y: drawingRect.maxY - 1.0)) context.withContext { c in @@ -472,6 +471,141 @@ func chatMessagePhoto(account: Account, photo: TelegramMediaImage) -> Signal<(Tr } } +private func chatMessagePhotoThumbnailDatas(account: Account, photo: TelegramMediaImage) -> Signal<(Data?, Data?, Bool), NoError> { + let fullRepresentationSize: CGSize = CGSize(width: 1280.0, height: 1280.0) + if let smallestRepresentation = smallestImageRepresentation(photo.representations), let largestRepresentation = photo.representationForDisplayAtSize(fullRepresentationSize) { + + let maybeFullSize = account.postbox.mediaBox.cachedResourceRepresentation(largestRepresentation.resource, representation: CachedScaledImageRepresentation(size: CGSize(width: 160.0, height: 160.0))) + + let signal = maybeFullSize |> take(1) |> mapToSignal { maybeData -> Signal<(Data?, Data?, Bool), NoError> in + if maybeData.complete { + let loadedData: Data? = try? Data(contentsOf: URL(fileURLWithPath: maybeData.path), options: []) + return .single((nil, loadedData, true)) + } else { + let fetchedThumbnail = account.postbox.mediaBox.fetchedResource(smallestRepresentation.resource) + + let thumbnail = Signal { subscriber in + let fetchedDisposable = fetchedThumbnail.start() + let thumbnailDisposable = account.postbox.mediaBox.resourceData(smallestRepresentation.resource).start(next: { next in + subscriber.putNext(next.size == 0 ? nil : try? Data(contentsOf: URL(fileURLWithPath: next.path), options: [])) + }, error: subscriber.putError, completed: subscriber.putCompletion) + + return ActionDisposable { + fetchedDisposable.dispose() + thumbnailDisposable.dispose() + } + } + + let fullSizeData: Signal<(Data?, Bool), NoError> = maybeFullSize + |> map { next -> (Data?, Bool) in + return (next.size == 0 ? nil : try? Data(contentsOf: URL(fileURLWithPath: next.path), options: []), next.complete) + } + + + return thumbnail |> mapToSignal { thumbnailData in + return fullSizeData |> map { (fullSizeData, complete) in + return (thumbnailData, fullSizeData, complete) + } + } + } + } |> filter({ $0.0 != nil || $0.1 != nil }) + + return signal + } else { + return .never() + } +} + +func chatMessagePhotoThumbnail(account: Account, photo: TelegramMediaImage) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { + let signal = chatMessagePhotoThumbnailDatas(account: account, photo: photo) + + return signal |> map { (thumbnailData, fullSizeData, fullSizeComplete) in + return { arguments in + let context = DrawingContext(size: arguments.drawingSize, clear: true) + + let drawingRect = arguments.drawingRect + var fittedSize = arguments.imageSize + if abs(fittedSize.width - arguments.boundingSize.width).isLessThanOrEqualTo(CGFloat(1.0)) { + fittedSize.width = arguments.boundingSize.width + } + if abs(fittedSize.height - arguments.boundingSize.height).isLessThanOrEqualTo(CGFloat(1.0)) { + fittedSize.height = arguments.boundingSize.height + } + + let fittedRect = CGRect(origin: CGPoint(x: drawingRect.origin.x + (drawingRect.size.width - fittedSize.width) / 2.0, y: drawingRect.origin.y + (drawingRect.size.height - fittedSize.height) / 2.0), size: fittedSize) + + var fullSizeImage: CGImage? + if let fullSizeData = fullSizeData { + if fullSizeComplete { + /*let options = NSMutableDictionary() + options.setValue(max(fittedSize.width * context.scale, fittedSize.height * context.scale) as NSNumber, forKey: kCGImageSourceThumbnailMaxPixelSize as String) + options.setValue(true as NSNumber, forKey: kCGImageSourceCreateThumbnailFromImageAlways as String) + if let imageSource = CGImageSourceCreateWithData(fullSizeData as CFData, nil), let image = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) { + fullSizeImage = image + }*/ + let options = NSMutableDictionary() + options[kCGImageSourceShouldCache as NSString] = false as NSNumber + if let imageSource = CGImageSourceCreateWithData(fullSizeData as CFData, nil), let image = CGImageSourceCreateImageAtIndex(imageSource, 0, options as CFDictionary) { + fullSizeImage = image + } + } else { + let imageSource = CGImageSourceCreateIncremental(nil) + CGImageSourceUpdateData(imageSource, fullSizeData as CFData, fullSizeComplete) + + let options = NSMutableDictionary() + options[kCGImageSourceShouldCache as NSString] = false as NSNumber + if let image = CGImageSourceCreateImageAtIndex(imageSource, 0, options as CFDictionary) { + fullSizeImage = image + } + } + } + + var thumbnailImage: CGImage? + if let thumbnailData = thumbnailData, let imageSource = CGImageSourceCreateWithData(thumbnailData as CFData, nil), let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) { + thumbnailImage = image + } + + var blurredThumbnailImage: UIImage? + if let thumbnailImage = thumbnailImage { + let thumbnailSize = CGSize(width: thumbnailImage.width, height: thumbnailImage.height) + let thumbnailContextSize = thumbnailSize.aspectFitted(CGSize(width: 150.0, height: 150.0)) + let thumbnailContext = DrawingContext(size: thumbnailContextSize, scale: 1.0) + thumbnailContext.withFlippedContext { c in + c.interpolationQuality = .none + c.draw(thumbnailImage, in: CGRect(origin: CGPoint(), size: thumbnailContextSize)) + } + telegramFastBlur(Int32(thumbnailContextSize.width), Int32(thumbnailContextSize.height), Int32(thumbnailContext.bytesPerRow), thumbnailContext.bytes) + + blurredThumbnailImage = thumbnailContext.generateImage() + } + + context.withFlippedContext { c in + c.setBlendMode(.copy) + if arguments.imageSize.width < arguments.boundingSize.width || arguments.imageSize.height < arguments.boundingSize.height { + //c.setFillColor(UIColor(white: 0.0, alpha: 0.4).cgColor) + c.fill(arguments.drawingRect) + } + + c.setBlendMode(.copy) + if let blurredThumbnailImage = blurredThumbnailImage, let cgImage = blurredThumbnailImage.cgImage { + c.interpolationQuality = .low + c.draw(cgImage, in: fittedRect) + c.setBlendMode(.normal) + } + + if let fullSizeImage = fullSizeImage { + c.interpolationQuality = .medium + c.draw(fullSizeImage, in: fittedRect) + } + } + + addCorners(context, arguments: arguments) + + return context + } + } +} + func chatSecretPhoto(account: Account, photo: TelegramMediaImage) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { let signal = chatMessagePhotoDatas(account: account, photo: photo) @@ -661,106 +795,7 @@ func mediaGridMessagePhoto(account: Account, photo: TelegramMediaImage) -> Signa } } -private struct ResourceImageSuitableForDisplayDataResult { - -} -private func resourceImageSuitableForDisplayDatas(account: Account, resourcesOrderedBySize: [MediaResource], displayPixelSize: CGSize) -> Signal { - return .never() -} - -func resourceImageSuitableForDisplay(account: Account, resourcesOrderedBySize: [MediaResource], displayPixelSize: CGSize) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { - let signal = resourceImageSuitableForDisplayDatas(account: account, resourcesOrderedBySize: resourcesOrderedBySize, displayPixelSize: displayPixelSize) - - return signal |> map { result in - return { arguments in - /*let context = DrawingContext(size: arguments.drawingSize, clear: true) - - let drawingRect = arguments.drawingRect - var fittedSize = arguments.imageSize - if abs(fittedSize.width - arguments.boundingSize.width).isLessThanOrEqualTo(CGFloat(1.0)) { - fittedSize.width = arguments.boundingSize.width - } - if abs(fittedSize.height - arguments.boundingSize.height).isLessThanOrEqualTo(CGFloat(1.0)) { - fittedSize.height = arguments.boundingSize.height - } - - let fittedRect = CGRect(origin: CGPoint(x: drawingRect.origin.x + (drawingRect.size.width - fittedSize.width) / 2.0, y: drawingRect.origin.y + (drawingRect.size.height - fittedSize.height) / 2.0), size: fittedSize) - - var fullSizeImage: CGImage? - if let fullSizeData = fullSizeData { - if fullSizeComplete { - /*let options = NSMutableDictionary() - options.setValue(max(fittedSize.width * context.scale, fittedSize.height * context.scale) as NSNumber, forKey: kCGImageSourceThumbnailMaxPixelSize as String) - options.setValue(true as NSNumber, forKey: kCGImageSourceCreateThumbnailFromImageAlways as String) - if let imageSource = CGImageSourceCreateWithData(fullSizeData as CFData, nil), let image = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) { - fullSizeImage = image - }*/ - let options = NSMutableDictionary() - options[kCGImageSourceShouldCache as NSString] = false as NSNumber - if let imageSource = CGImageSourceCreateWithData(fullSizeData as CFData, nil), let image = CGImageSourceCreateImageAtIndex(imageSource, 0, options as CFDictionary) { - fullSizeImage = image - } - } else { - let imageSource = CGImageSourceCreateIncremental(nil) - CGImageSourceUpdateData(imageSource, fullSizeData as CFData, fullSizeComplete) - - let options = NSMutableDictionary() - options[kCGImageSourceShouldCache as NSString] = false as NSNumber - if let image = CGImageSourceCreateImageAtIndex(imageSource, 0, options as CFDictionary) { - fullSizeImage = image - } - } - } - - var thumbnailImage: CGImage? - if let thumbnailData = thumbnailData, let imageSource = CGImageSourceCreateWithData(thumbnailData as CFData, nil), let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) { - thumbnailImage = image - } - - var blurredThumbnailImage: UIImage? - if let thumbnailImage = thumbnailImage { - let thumbnailSize = CGSize(width: thumbnailImage.width, height: thumbnailImage.height) - let thumbnailContextSize = thumbnailSize.aspectFitted(CGSize(width: 150.0, height: 150.0)) - let thumbnailContext = DrawingContext(size: thumbnailContextSize, scale: 1.0) - thumbnailContext.withFlippedContext { c in - c.interpolationQuality = .none - c.draw(thumbnailImage, in: CGRect(origin: CGPoint(), size: thumbnailContextSize)) - } - telegramFastBlur(Int32(thumbnailContextSize.width), Int32(thumbnailContextSize.height), Int32(thumbnailContext.bytesPerRow), thumbnailContext.bytes) - - blurredThumbnailImage = thumbnailContext.generateImage() - } - - context.withFlippedContext { c in - c.setBlendMode(.copy) - if arguments.imageSize.width < arguments.boundingSize.width || arguments.imageSize.height < arguments.boundingSize.height { - //c.setFillColor(UIColor(white: 0.0, alpha: 0.4).cgColor) - c.fill(arguments.drawingRect) - } - - c.setBlendMode(.copy) - if let blurredThumbnailImage = blurredThumbnailImage, let cgImage = blurredThumbnailImage.cgImage { - c.interpolationQuality = .low - c.draw(cgImage, in: fittedRect) - c.setBlendMode(.normal) - } - - if let fullSizeImage = fullSizeImage { - c.interpolationQuality = .medium - c.draw(fullSizeImage, in: fittedRect) - } - } - - addCorners(context, arguments: arguments) - - return context - */ - - return nil - } - } -} func chatMessagePhotoStatus(account: Account, photo: TelegramMediaImage) -> Signal { if let largestRepresentation = largestRepresentationForPhoto(photo) { diff --git a/TelegramUI/PresentationData.swift b/TelegramUI/PresentationData.swift new file mode 100644 index 0000000000..cae4e8ec4c --- /dev/null +++ b/TelegramUI/PresentationData.swift @@ -0,0 +1,5 @@ +import Foundation + +final class PresentationData { + +} diff --git a/TelegramUI/PrivacyAndSecurityController.swift b/TelegramUI/PrivacyAndSecurityController.swift index 38cb4e2b6f..ef034d812a 100644 --- a/TelegramUI/PrivacyAndSecurityController.swift +++ b/TelegramUI/PrivacyAndSecurityController.swift @@ -164,18 +164,72 @@ private enum PrivacyAndSecurityEntry: ItemListNodeEntry { arguments.setupAccountAutoremove() }) case .accountInfo: - return ItemListTextItem(text: "If you do not log in at least once within this period, your account will be deleted along with all groups, messages and contacts.", sectionId: self.section) + return ItemListTextItem(text: .plain("If you do not log in at least once within this period, your account will be deleted along with all groups, messages and contacts."), sectionId: self.section) } } } private struct PrivacyAndSecurityControllerState: Equatable { + let updatingAccountTimeoutValue: Int32? + init() { + self.updatingAccountTimeoutValue = nil + } + + init(updatingAccountTimeoutValue: Int32?) { + self.updatingAccountTimeoutValue = updatingAccountTimeoutValue } static func ==(lhs: PrivacyAndSecurityControllerState, rhs: PrivacyAndSecurityControllerState) -> Bool { + if lhs.updatingAccountTimeoutValue != rhs.updatingAccountTimeoutValue { + return false + } + return true } + + func withUpdatedUpdatingAccountTimeoutValue(_ updatingAccountTimeoutValue: Int32?) -> PrivacyAndSecurityControllerState { + return PrivacyAndSecurityControllerState(updatingAccountTimeoutValue: updatingAccountTimeoutValue) + } +} + +private func stringForSelectiveSettings(_ settings: SelectivePrivacySettings) -> String { + switch settings { + case let .disableEveryone(enableFor): + if enableFor.isEmpty { + return "Nobody" + } else { + return "Nobody (+\(enableFor.count))" + } + case let .enableEveryone(disableFor): + if disableFor.isEmpty { + return "Everybody" + } else { + return "Everybody (-\(disableFor.count))" + } + case let .enableContacts(enableFor, disableFor): + if !enableFor.isEmpty && !disableFor.isEmpty { + return "My Contacts (+\(enableFor.count), -\(disableFor.count))" + } else if !enableFor.isEmpty { + return "My Contacts (+\(enableFor.count))" + } else if !disableFor.isEmpty { + return "My Contacts (-\(disableFor.count))" + } else { + return "My Contacts" + } + } +} + +private func stringForAccountTimeout(_ timeout: Int32) -> String { + if timeout <= 1 * 31 * 24 * 60 * 60 { + return "1 month" + } else if timeout <= 3 * 31 * 24 * 60 * 60 { + return "3 months" + } else if timeout <= 6 * 31 * 24 * 60 * 60 { + return "6 months" + } else { + return "1 year" + } } private func privacyAndSecurityControllerEntries(state: PrivacyAndSecurityControllerState, privacySettings: AccountPrivacySettings?) -> [PrivacyAndSecurityEntry] { @@ -183,22 +237,38 @@ private func privacyAndSecurityControllerEntries(state: PrivacyAndSecurityContro entries.append(.privacyHeader) entries.append(.blockedPeers) - entries.append(.lastSeenPrivacy("")) - entries.append(.groupPrivacy("")) - entries.append(.voiceCallPrivacy("")) + if let privacySettings = privacySettings { + entries.append(.lastSeenPrivacy(stringForSelectiveSettings(privacySettings.presence))) + entries.append(.groupPrivacy(stringForSelectiveSettings(privacySettings.groupInvitations))) + entries.append(.voiceCallPrivacy(stringForSelectiveSettings(privacySettings.voiceCalls))) + } else { + entries.append(.lastSeenPrivacy("Loading")) + entries.append(.groupPrivacy("Loading")) + entries.append(.voiceCallPrivacy("Loading")) + } entries.append(.securityHeader) entries.append(.passcode) entries.append(.twoStepVerification) entries.append(.activeSessions) entries.append(.accountHeader) - entries.append(.accountTimeout("")) + if let privacySettings = privacySettings { + let value: Int32 + if let updatingAccountTimeoutValue = state.updatingAccountTimeoutValue { + value = updatingAccountTimeoutValue + } else { + value = privacySettings.accountRemovalTimeout + } + entries.append(.accountTimeout(stringForAccountTimeout(value))) + } else { + entries.append(.accountTimeout("Loading")) + } entries.append(.accountInfo) return entries } -public func privacyAndSecurityController(account: Account) -> ViewController { +public func privacyAndSecurityController(account: Account, initialSettings: Signal) -> ViewController { let statePromise = ValuePromise(PrivacyAndSecurityControllerState(), ignoreRepeated: true) let stateValue = Atomic(value: PrivacyAndSecurityControllerState()) let updateState: ((PrivacyAndSecurityControllerState) -> PrivacyAndSecurityControllerState) -> Void = { f in @@ -206,41 +276,161 @@ public func privacyAndSecurityController(account: Account) -> ViewController { } var pushControllerImpl: ((ViewController) -> Void)? + var presentControllerImpl: ((ViewController) -> Void)? let actionsDisposable = DisposableSet() - let checkAddressNameDisposable = MetaDisposable() - actionsDisposable.add(checkAddressNameDisposable) + let currentInfoDisposable = MetaDisposable() + actionsDisposable.add(currentInfoDisposable) - let updateAddressNameDisposable = MetaDisposable() - actionsDisposable.add(updateAddressNameDisposable) + let updateAccountTimeoutDisposable = MetaDisposable() + actionsDisposable.add(updateAccountTimeoutDisposable) + + let privacySettingsPromise = Promise() + privacySettingsPromise.set(initialSettings) let arguments = PrivacyAndSecurityControllerArguments(account: account, openBlockedUsers: { pushControllerImpl?(blockedPeersController(account: account)) }, openLastSeenPrivacy: { - + let signal = privacySettingsPromise.get() + |> take(1) + |> deliverOnMainQueue + currentInfoDisposable.set(signal.start(next: { [weak currentInfoDisposable] info in + if let info = info { + pushControllerImpl?(selectivePrivacySettingsController(account: account, kind: .presence, current: info.presence, updated: { updated in + if let currentInfoDisposable = currentInfoDisposable { + let applySetting: Signal = privacySettingsPromise.get() + |> filter { $0 != nil } + |> take(1) + |> deliverOnMainQueue + |> mapToSignal { value -> Signal in + if let value = value { + privacySettingsPromise.set(.single(AccountPrivacySettings(presence: updated, groupInvitations: value.groupInvitations, voiceCalls: value.voiceCalls, accountRemovalTimeout: value.accountRemovalTimeout))) + } + return .complete() + } + currentInfoDisposable.set(applySetting.start()) + } + })) + } + })) }, openGroupsPrivacy: { - + let signal = privacySettingsPromise.get() + |> take(1) + |> deliverOnMainQueue + currentInfoDisposable.set(signal.start(next: { [weak currentInfoDisposable] info in + if let info = info { + pushControllerImpl?(selectivePrivacySettingsController(account: account, kind: .groupInvitations, current: info.groupInvitations, updated: { updated in + if let currentInfoDisposable = currentInfoDisposable { + let applySetting: Signal = privacySettingsPromise.get() + |> filter { $0 != nil } + |> take(1) + |> deliverOnMainQueue + |> mapToSignal { value -> Signal in + if let value = value { + privacySettingsPromise.set(.single(AccountPrivacySettings(presence: value.presence, groupInvitations: updated, voiceCalls: value.voiceCalls, accountRemovalTimeout: value.accountRemovalTimeout))) + } + return .complete() + } + currentInfoDisposable.set(applySetting.start()) + } + })) + } + })) }, openVoiceCallPrivacy: { - + let signal = privacySettingsPromise.get() + |> take(1) + |> deliverOnMainQueue + currentInfoDisposable.set(signal.start(next: { [weak currentInfoDisposable] info in + if let info = info { + pushControllerImpl?(selectivePrivacySettingsController(account: account, kind: .voiceCalls, current: info.voiceCalls, updated: { updated in + if let currentInfoDisposable = currentInfoDisposable { + let applySetting: Signal = privacySettingsPromise.get() + |> filter { $0 != nil } + |> take(1) + |> deliverOnMainQueue + |> mapToSignal { value -> Signal in + if let value = value { + privacySettingsPromise.set(.single(AccountPrivacySettings(presence: value.presence, groupInvitations: value.groupInvitations, voiceCalls: updated, accountRemovalTimeout: value.accountRemovalTimeout))) + } + return .complete() + } + currentInfoDisposable.set(applySetting.start()) + } + })) + } + })) }, openPasscode: { }, openTwoStepVerification: { - + pushControllerImpl?(twoStepVerificationUnlockSettingsController(account: account, mode: .access)) }, openActiveSessions: { pushControllerImpl?(recentSessionsController(account: account)) }, setupAccountAutoremove: { - + let signal = privacySettingsPromise.get() + |> take(1) + |> deliverOnMainQueue + updateAccountTimeoutDisposable.set(signal.start(next: { [weak updateAccountTimeoutDisposable] privacySettingsValue in + if let _ = privacySettingsValue { + let controller = ActionSheetController() + let dismissAction: () -> Void = { [weak controller] in + controller?.dismissAnimated() + } + let timeoutAction: (Int32) -> Void = { timeout in + if let updateAccountTimeoutDisposable = updateAccountTimeoutDisposable { + updateState { + return $0.withUpdatedUpdatingAccountTimeoutValue(timeout) + } + let applyTimeout: Signal = privacySettingsPromise.get() + |> filter { $0 != nil } + |> take(1) + |> deliverOnMainQueue + |> mapToSignal { value -> Signal in + if let value = value { + privacySettingsPromise.set(.single(AccountPrivacySettings(presence: value.presence, groupInvitations: value.groupInvitations, voiceCalls: value.voiceCalls, accountRemovalTimeout: timeout))) + } + return .complete() + } + updateAccountTimeoutDisposable.set((updateAccountRemovalTimeout(account: account, timeout: timeout) + |> then(applyTimeout) + |> deliverOnMainQueue).start(completed: { + updateState { + return $0.withUpdatedUpdatingAccountTimeoutValue(nil) + } + })) + } + } + controller.setItemGroups([ + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "1 month", action: { + dismissAction() + timeoutAction(1 * 30 * 24 * 60 * 60) + }), + ActionSheetButtonItem(title: "3 months", action: { + dismissAction() + timeoutAction(3 * 30 * 24 * 60 * 60) + }), + ActionSheetButtonItem(title: "6 months", action: { + dismissAction() + timeoutAction(6 * 30 * 24 * 60 * 60) + }), + ActionSheetButtonItem(title: "1 year", action: { + dismissAction() + timeoutAction(12 * 30 * 24 * 60 * 60) + }), + ]), + ActionSheetItemGroup(items: [ActionSheetButtonItem(title: "Cancel", action: { dismissAction() })]) + ]) + presentControllerImpl?(controller) + } + })) }) - let privacySettings: Signal = .single(nil) |> then(updatedAccountPrivacySettings(account: account) |> map { Optional($0) }) - |> deliverOnMainQueue - - let signal = combineLatest(statePromise.get() |> deliverOnMainQueue, privacySettings) + let signal = combineLatest(statePromise.get() |> deliverOnMainQueue, privacySettingsPromise.get()) |> map { state, privacySettings -> (ItemListControllerState, (ItemListNodeState, PrivacyAndSecurityEntry.ItemGenerationArguments)) in var rightNavigationButton: ItemListNavigationButton? - if privacySettings == nil { + if privacySettings == nil || state.updatingAccountTimeoutValue != nil { rightNavigationButton = ItemListNavigationButton(title: "", style: .activity, enabled: true, action: {}) } @@ -250,13 +440,16 @@ public func privacyAndSecurityController(account: Account) -> ViewController { return (controllerState, (listState, arguments)) } |> afterDisposed { actionsDisposable.dispose() - } + } let controller = ItemListController(signal) controller.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) pushControllerImpl = { [weak controller] c in (controller?.navigationController as? NavigationController)?.pushViewController(c) } + presentControllerImpl = { [weak controller] c in + controller?.present(c, in: .window, with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + } return controller } diff --git a/TelegramUI/RadialProgressNode.swift b/TelegramUI/RadialProgressNode.swift index f4e9c7c68b..18cd308eb4 100644 --- a/TelegramUI/RadialProgressNode.swift +++ b/TelegramUI/RadialProgressNode.swift @@ -2,6 +2,7 @@ import Foundation import AsyncDisplayKit import SwiftSignalKit import Display +import TelegramLegacyComponents private class RadialProgressParameters: NSObject { let theme: RadialProgressTheme @@ -34,8 +35,40 @@ private class RadialProgressOverlayParameters: NSObject { private class RadialProgressOverlayNode: ASDisplayNode { let theme: RadialProgressTheme + var previousProgress: Float? + var effectiveProgress: Float = 0.0 { + didSet { + if oldValue != self.effectiveProgress { + self.setNeedsDisplay() + } + } + } + + var progressAnimationCompleted: (() -> Void)? + var state: RadialProgressState = .None { didSet { + if case let .Fetching(progress) = oldValue { + let animation = POPBasicAnimation() + animation.property = POPAnimatableProperty.property(withName: "progress", initializer: { property in + property?.readBlock = { node, values in + values?.pointee = CGFloat((node as! RadialProgressOverlayNode).effectiveProgress) + } + property?.writeBlock = { node, values in + (node as! RadialProgressOverlayNode).effectiveProgress = Float(values!.pointee) + } + property?.threshold = 0.01 + }) as! POPAnimatableProperty + animation.fromValue = CGFloat(effectiveProgress) as NSNumber + animation.toValue = CGFloat(progress) as NSNumber + animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear) + animation.duration = 0.2 + animation.completionBlock = { [weak self] _ in + self?.progressAnimationCompleted?() + } + self.pop_removeAnimation(forKey: "progress") + self.pop_add(animation, forKey: "progress") + } self.setNeedsDisplay() } } @@ -46,10 +79,15 @@ private class RadialProgressOverlayNode: ASDisplayNode { super.init() self.isOpaque = false + self.displaysAsynchronously = true } override func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? { - return RadialProgressOverlayParameters(theme: self.theme, diameter: self.frame.size.width, state: self.state) + var updatedState = self.state + if case let .Fetching = updatedState { + updatedState = .Fetching(progress: self.effectiveProgress) + } + return RadialProgressOverlayParameters(theme: self.theme, diameter: self.frame.size.width, state: updatedState) } @objc override public class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: () -> Bool, isRasterizing: Bool) { @@ -71,7 +109,7 @@ private class RadialProgressOverlayNode: ASDisplayNode { break case let .Fetching(progress): let startAngle = -CGFloat(M_PI_2) - let endAngle = 2.0 * (CGFloat(M_PI)) * CGFloat(progress) - CGFloat(M_PI_2) + let endAngle = 2.0 * CGFloat(M_PI) * CGFloat(progress) - CGFloat(M_PI_2) let pathDiameter = parameters.diameter - 2.25 - 2.5 * 2.0 @@ -133,7 +171,15 @@ class RadialProgressNode: ASControlNode { } } else { if self.overlay.supernode != nil { - self.overlay.removeFromSupernode() + /*if case let .Fetching(progress) = oldValue { + let overlay = self.overlay + overlay.state = .Fetching(progress: 1.0) + overlay.progressAnimationCompleted = { [weak overlay] in + overlay?.removeFromSupernode() + } + } else {*/ + self.overlay.removeFromSupernode() + //} } } switch oldValue { diff --git a/TelegramUI/RecentSessionsController.swift b/TelegramUI/RecentSessionsController.swift index d350cc08c0..acd7241bbb 100644 --- a/TelegramUI/RecentSessionsController.swift +++ b/TelegramUI/RecentSessionsController.swift @@ -9,11 +9,13 @@ private final class RecentSessionsControllerArguments { let setSessionIdWithRevealedOptions: (Int64?, Int64?) -> Void let removeSession: (Int64) -> Void + let terminateOtherSessions: () -> Void - init(account: Account, setSessionIdWithRevealedOptions: @escaping (Int64?, Int64?) -> Void, removeSession: @escaping (Int64) -> Void) { + init(account: Account, setSessionIdWithRevealedOptions: @escaping (Int64?, Int64?) -> Void, removeSession: @escaping (Int64) -> Void, terminateOtherSessions: @escaping () -> Void) { self.account = account self.setSessionIdWithRevealedOptions = setSessionIdWithRevealedOptions self.removeSession = removeSession + self.terminateOtherSessions = terminateOtherSessions } } @@ -139,10 +141,10 @@ private enum RecentSessionsEntry: ItemListNodeEntry { }) case .terminateOtherSessions: return ItemListActionItem(title: "Terminate all other sessions", kind: .destructive, alignment: .natural, sectionId: self.section, style: .blocks, action: { - + arguments.terminateOtherSessions() }) case .currentSessionInfo: - return ItemListTextItem(text: "Logs out all devices except for this one.", sectionId: self.section) + return ItemListTextItem(text: .plain("Logs out all devices except for this one."), sectionId: self.section) case .otherSessionsHeader: return ItemListSectionHeaderItem(text: "ACTIVE SESSIONS", sectionId: self.section) case let .session(_, session, enabled, editing, revealed): @@ -159,17 +161,20 @@ private struct RecentSessionsControllerState: Equatable { let editing: Bool let sessionIdWithRevealedOptions: Int64? let removingSessionId: Int64? + let terminatingOtherSessions: Bool init() { self.editing = false self.sessionIdWithRevealedOptions = nil self.removingSessionId = nil + self.terminatingOtherSessions = false } - init(editing: Bool, sessionIdWithRevealedOptions: Int64?, removingSessionId: Int64?) { + init(editing: Bool, sessionIdWithRevealedOptions: Int64?, removingSessionId: Int64?, terminatingOtherSessions: Bool) { self.editing = editing self.sessionIdWithRevealedOptions = sessionIdWithRevealedOptions self.removingSessionId = removingSessionId + self.terminatingOtherSessions = terminatingOtherSessions } static func ==(lhs: RecentSessionsControllerState, rhs: RecentSessionsControllerState) -> Bool { @@ -182,20 +187,27 @@ private struct RecentSessionsControllerState: Equatable { if lhs.removingSessionId != rhs.removingSessionId { return false } + if lhs.terminatingOtherSessions != rhs.terminatingOtherSessions { + return false + } return true } func withUpdatedEditing(_ editing: Bool) -> RecentSessionsControllerState { - return RecentSessionsControllerState(editing: editing, sessionIdWithRevealedOptions: self.sessionIdWithRevealedOptions, removingSessionId: self.removingSessionId) + return RecentSessionsControllerState(editing: editing, sessionIdWithRevealedOptions: self.sessionIdWithRevealedOptions, removingSessionId: self.removingSessionId, terminatingOtherSessions: self.terminatingOtherSessions) } func withUpdatedSessionIdWithRevealedOptions(_ sessionIdWithRevealedOptions: Int64?) -> RecentSessionsControllerState { - return RecentSessionsControllerState(editing: self.editing, sessionIdWithRevealedOptions: sessionIdWithRevealedOptions, removingSessionId: self.removingSessionId) + return RecentSessionsControllerState(editing: self.editing, sessionIdWithRevealedOptions: sessionIdWithRevealedOptions, removingSessionId: self.removingSessionId, terminatingOtherSessions: self.terminatingOtherSessions) } func withUpdatedRemovingSessionId(_ removingSessionId: Int64?) -> RecentSessionsControllerState { - return RecentSessionsControllerState(editing: self.editing, sessionIdWithRevealedOptions: self.sessionIdWithRevealedOptions, removingSessionId: removingSessionId) + return RecentSessionsControllerState(editing: self.editing, sessionIdWithRevealedOptions: self.sessionIdWithRevealedOptions, removingSessionId: removingSessionId, terminatingOtherSessions: self.terminatingOtherSessions) + } + + func withUpdatedTerminatingOtherSessions(_ terminatingOtherSessions: Bool) -> RecentSessionsControllerState { + return RecentSessionsControllerState(editing: self.editing, sessionIdWithRevealedOptions: self.sessionIdWithRevealedOptions, removingSessionId: self.removingSessionId, terminatingOtherSessions: terminatingOtherSessions) } } @@ -209,10 +221,11 @@ private func recentSessionsControllerEntries(state: RecentSessionsControllerStat existingSessionIds.insert(sessions[index].hash) entries.append(.currentSession(sessions[index])) } - entries.append(.terminateOtherSessions) - entries.append(.currentSessionInfo) if sessions.count > 1 { + entries.append(.terminateOtherSessions) + entries.append(.currentSessionInfo) + entries.append(.otherSessionsHeader) let filteredSessions: [RecentAccountSession] = sessions.sorted(by: { lhs, rhs in @@ -222,7 +235,7 @@ private func recentSessionsControllerEntries(state: RecentSessionsControllerStat for i in 0 ..< filteredSessions.count { if !existingSessionIds.contains(sessions[i].hash) { existingSessionIds.insert(sessions[i].hash) - entries.append(.session(index: Int32(i), session: sessions[i], enabled: state.removingSessionId != sessions[i].hash, editing: state.editing, revealed: state.sessionIdWithRevealedOptions == sessions[i].hash)) + entries.append(.session(index: Int32(i), session: sessions[i], enabled: state.removingSessionId != sessions[i].hash && !state.terminatingOtherSessions, editing: state.editing, revealed: state.sessionIdWithRevealedOptions == sessions[i].hash)) } } } @@ -245,6 +258,9 @@ public func recentSessionsController(account: Account) -> ViewController { let removeSessionDisposable = MetaDisposable() actionsDisposable.add(removeSessionDisposable) + let terminateOtherSessionsDisposable = MetaDisposable() + actionsDisposable.add(terminateOtherSessionsDisposable) + let sessionsPromise = Promise<[RecentAccountSession]?>(nil) let arguments = RecentSessionsControllerArguments(account: account, setSessionIdWithRevealedOptions: { sessionId, fromSessionId in @@ -288,6 +304,47 @@ public func recentSessionsController(account: Account) -> ViewController { return $0.withUpdatedRemovingSessionId(nil) } })) + }, terminateOtherSessions: { + let controller = ActionSheetController() + let dismissAction: () -> Void = { [weak controller] in + controller?.dismissAnimated() + } + controller.setItemGroups([ + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "Terminate all other sessions", color: .destructive, action: { + dismissAction() + + updateState { + return $0.withUpdatedTerminatingOtherSessions(true) + } + + let applySessions: Signal = sessionsPromise.get() + |> filter { $0 != nil } + |> take(1) + |> deliverOnMainQueue + |> mapToSignal { sessions -> Signal in + if let sessions = sessions { + let updatedSessions = sessions.filter { $0.isCurrent } + sessionsPromise.set(.single(updatedSessions)) + } + + return .complete() + } + + terminateOtherSessionsDisposable.set((terminateOtherAccountSessions(account: account) |> then(applySessions)).start(error: { _ in + updateState { + return $0.withUpdatedTerminatingOtherSessions(false) + } + }, completed: { + updateState { + return $0.withUpdatedTerminatingOtherSessions(false) + } + })) + }) + ]), + ActionSheetItemGroup(items: [ActionSheetButtonItem(title: "Cancel", action: { dismissAction() })]) + ]) + presentControllerImpl?(controller, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) }) let sessionsSignal: Signal<[RecentAccountSession]?, NoError> = .single(nil) |> then(requestRecentAccountSessions(account: account) |> map { Optional($0) }) @@ -300,8 +357,10 @@ public func recentSessionsController(account: Account) -> ViewController { |> deliverOnMainQueue |> map { state, sessions -> (ItemListControllerState, (ItemListNodeState, RecentSessionsEntry.ItemGenerationArguments)) in var rightNavigationButton: ItemListNavigationButton? - if let sessions = sessions, !sessions.isEmpty { - if state.editing { + if let sessions = sessions, sessions.count > 1 { + if state.terminatingOtherSessions { + rightNavigationButton = ItemListNavigationButton(title: "", style: .activity, enabled: true, action: {}) + } else if state.editing { rightNavigationButton = ItemListNavigationButton(title: "Done", style: .bold, enabled: true, action: { updateState { state in return state.withUpdatedEditing(false) diff --git a/TelegramUI/SecretMediaPreviewController.swift b/TelegramUI/SecretMediaPreviewController.swift index 1aeeaf7d78..cd4fcc8cc8 100644 --- a/TelegramUI/SecretMediaPreviewController.swift +++ b/TelegramUI/SecretMediaPreviewController.swift @@ -94,7 +94,7 @@ public final class SecretMediaPreviewController: ViewController { self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: 0.0, transition: transition) } - public func dismiss() { + override open func dismiss() { self.presentingViewController?.dismiss(animated: false, completion: nil) } } diff --git a/TelegramUI/SelectivePrivacySettingsController.swift b/TelegramUI/SelectivePrivacySettingsController.swift new file mode 100644 index 0000000000..2e7e56f433 --- /dev/null +++ b/TelegramUI/SelectivePrivacySettingsController.swift @@ -0,0 +1,442 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +enum SelectivePrivacySettingsKind { + case presence + case groupInvitations + case voiceCalls +} + +private enum SelectivePrivacySettingType { + case everybody + case contacts + case nobody + + init(_ setting: SelectivePrivacySettings) { + switch setting { + case .disableEveryone: + self = .nobody + case .enableContacts: + self = .contacts + case .enableEveryone: + self = .everybody + } + } +} + +private final class SelectivePrivacySettingsControllerArguments { + let account: Account + + let updateType: (SelectivePrivacySettingType) -> Void + let openEnableFor: () -> Void + let openDisableFor: () -> Void + + init(account: Account, updateType: @escaping (SelectivePrivacySettingType) -> Void, openEnableFor: @escaping () -> Void, openDisableFor: @escaping () -> Void) { + self.account = account + self.updateType = updateType + self.openEnableFor = openEnableFor + self.openDisableFor = openDisableFor + } +} + +private enum SelectivePrivacySettingsSection: Int32 { + case setting + case peers +} + +private func stringForUserCount(_ count: Int) -> String { + if count == 0 { + return "Add Users" + } else if count == 1 { + return "1 user" + } else { + return "\(count) users" + } +} + +private enum SelectivePrivacySettingsEntry: ItemListNodeEntry { + case settingHeader(String) + case everybody(Bool) + case contacts(Bool) + case nobody(Bool) + case settingInfo(String) + case disableFor(String, Int) + case enableFor(String, Int) + case peersInfo + + var section: ItemListSectionId { + switch self { + case .settingHeader, .everybody, .contacts, .nobody, .settingInfo: + return SelectivePrivacySettingsSection.setting.rawValue + case .disableFor, .enableFor, .peersInfo: + return SelectivePrivacySettingsSection.peers.rawValue + } + } + + var stableId: Int32 { + switch self { + case .settingHeader: + return 0 + case .everybody: + return 1 + case .contacts: + return 2 + case .nobody: + return 3 + case .settingInfo: + return 4 + case .disableFor: + return 5 + case .enableFor: + return 6 + case .peersInfo: + return 7 + } + } + + static func ==(lhs: SelectivePrivacySettingsEntry, rhs: SelectivePrivacySettingsEntry) -> Bool { + switch lhs { + case let .settingHeader(text): + if case .settingHeader(text) = rhs { + return true + } else { + return false + } + case let .everybody(value): + if case .everybody(value) = rhs { + return true + } else { + return false + } + case let .contacts(value): + if case .contacts(value) = rhs { + return true + } else { + return false + } + case let .nobody(value): + if case .nobody(value) = rhs { + return true + } else { + return false + } + case let .settingInfo(text): + if case .settingInfo(text) = rhs { + return true + } else { + return false + } + case let .disableFor(title, count): + if case .disableFor(title, count) = rhs { + return true + } else { + return false + } + case let .enableFor(title, count): + if case .enableFor(title, count) = rhs { + return true + } else { + return false + } + case .peersInfo: + if case .peersInfo = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: SelectivePrivacySettingsEntry, rhs: SelectivePrivacySettingsEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(_ arguments: SelectivePrivacySettingsControllerArguments) -> ListViewItem { + switch self { + case let .settingHeader(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + case let .everybody(value): + return ItemListCheckboxItem(title: "Everybody", checked: value, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.updateType(.everybody) + }) + case let .contacts(value): + return ItemListCheckboxItem(title: "My Contacts", checked: value, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.updateType(.contacts) + }) + case let .nobody(value): + return ItemListCheckboxItem(title: "Nobody", checked: value, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.updateType(.nobody) + }) + case let .settingInfo(text): + return ItemListTextItem(text: .plain(text), sectionId: self.section) + case let .disableFor(title, count): + return ItemListDisclosureItem(title: title, label: stringForUserCount(count), sectionId: self.section, style: .blocks, action: { + arguments.openDisableFor() + }) + case let .enableFor(title, count): + return ItemListDisclosureItem(title: title, label: stringForUserCount(count), sectionId: self.section, style: .blocks, action: { + arguments.openEnableFor() + }) + case .peersInfo: + return ItemListTextItem(text: .plain("These settings will override the values above."), sectionId: self.section) + } + } +} + +private struct SelectivePrivacySettingsControllerState: Equatable { + let setting: SelectivePrivacySettingType + let enableFor: Set + let disableFor: Set + + let saving: Bool + + init(setting: SelectivePrivacySettingType, enableFor: Set, disableFor: Set, saving: Bool) { + self.setting = setting + self.enableFor = enableFor + self.disableFor = disableFor + self.saving = saving + } + + static func ==(lhs: SelectivePrivacySettingsControllerState, rhs: SelectivePrivacySettingsControllerState) -> Bool { + if lhs.setting != rhs.setting { + return false + } + if lhs.enableFor != rhs.enableFor { + return false + } + if lhs.disableFor != rhs.disableFor { + return false + } + if lhs.saving != rhs.saving { + return false + } + + return true + } + + func withUpdatedSetting(_ setting: SelectivePrivacySettingType) -> SelectivePrivacySettingsControllerState { + return SelectivePrivacySettingsControllerState(setting: setting, enableFor: self.enableFor, disableFor: self.disableFor, saving: self.saving) + } + + func withUpdatedEnableFor(_ enableFor: Set) -> SelectivePrivacySettingsControllerState { + return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: enableFor, disableFor: self.disableFor, saving: self.saving) + } + + func withUpdatedDisableFor(_ disableFor: Set) -> SelectivePrivacySettingsControllerState { + return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: disableFor, saving: self.saving) + } + + func withUpdatedSaving(_ saving: Bool) -> SelectivePrivacySettingsControllerState { + return SelectivePrivacySettingsControllerState(setting: self.setting, enableFor: self.enableFor, disableFor: self.disableFor, saving: saving) + } +} + +private func selectivePrivacySettingsControllerEntries(kind: SelectivePrivacySettingsKind, state: SelectivePrivacySettingsControllerState) -> [SelectivePrivacySettingsEntry] { + var entries: [SelectivePrivacySettingsEntry] = [] + + let settingTitle: String + let settingInfoText: String + let disableForText: String + let enableForText: String + switch kind { + case .presence: + settingTitle = "WHO CAN SEE MY TIMESTAMP" + settingInfoText = "Important: you won't be able to see Last Seen times for people with whom you don't share your Last Seen time. Approximate last seen will be shown instead (recently, within a week, within a month)." + disableForText = "Never Share With" + enableForText = "Always Share With" + case .groupInvitations: + settingTitle = "WHO CAN ADD ME TO GROUP CHATS" + settingInfoText = "You can restrict who can add you to groups and channels with granular precision." + disableForText = "Never Allow" + enableForText = "Always Allow" + case .voiceCalls: + settingTitle = "WHO CAN CALL ME" + settingInfoText = "You can restrict who can call you with granular precision." + disableForText = "Never Allow" + enableForText = "Always Allow" + } + + entries.append(.settingHeader(settingTitle)) + + entries.append(.everybody(state.setting == .everybody)) + entries.append(.contacts(state.setting == .contacts)) + switch kind { + case .presence, .voiceCalls: + entries.append(.nobody(state.setting == .nobody)) + case .groupInvitations: + break + } + entries.append(.settingInfo(settingInfoText)) + + switch state.setting { + case .everybody: + entries.append(.disableFor(disableForText, state.disableFor.count)) + case .contacts: + entries.append(.disableFor(disableForText, state.disableFor.count)) + entries.append(.enableFor(enableForText, state.enableFor.count)) + case .nobody: + entries.append(.enableFor(enableForText, state.enableFor.count)) + } + entries.append(.peersInfo) + + return entries +} + +func selectivePrivacySettingsController(account: Account, kind: SelectivePrivacySettingsKind, current: SelectivePrivacySettings, updated: @escaping (SelectivePrivacySettings) -> Void) -> ViewController { + var initialEnableFor = Set() + var initialDisableFor = Set() + switch current { + case let .disableEveryone(enableFor): + initialEnableFor = enableFor + case let .enableContacts(enableFor, disableFor): + initialEnableFor = enableFor + initialDisableFor = disableFor + case let .enableEveryone(disableFor): + initialDisableFor = disableFor + } + let initialState = SelectivePrivacySettingsControllerState(setting: SelectivePrivacySettingType(current), enableFor: initialEnableFor, disableFor: initialDisableFor, saving: false) + + let statePromise = ValuePromise(initialState, ignoreRepeated: true) + let stateValue = Atomic(value: initialState) + let updateState: ((SelectivePrivacySettingsControllerState) -> SelectivePrivacySettingsControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var dismissImpl: (() -> Void)? + var pushControllerImpl: ((ViewController) -> Void)? + var presentControllerImpl: ((ViewController) -> Void)? + + let actionsDisposable = DisposableSet() + + let updateSettingsDisposable = MetaDisposable() + actionsDisposable.add(updateSettingsDisposable) + + let arguments = SelectivePrivacySettingsControllerArguments(account: account, updateType: { type in + updateState { + $0.withUpdatedSetting(type) + } + }, openEnableFor: { + let title: String + switch kind { + case .presence: + title = "Always Share With" + case .groupInvitations: + title = "Always Allow" + case .voiceCalls: + title = "Always Allow" + } + var peerIds = Set() + updateState { state in + peerIds = state.enableFor + return state + } + pushControllerImpl?(selectivePrivacyPeersController(account: account, title: title, initialPeerIds: Array(peerIds), updated: { updatedPeerIds in + updateState { state in + return state.withUpdatedEnableFor(Set(updatedPeerIds)).withUpdatedDisableFor(state.disableFor.subtracting(Set(updatedPeerIds))) + } + })) + }, openDisableFor: { + let title: String + switch kind { + case .presence: + title = "Never Share With" + case .groupInvitations: + title = "Never Allow" + case .voiceCalls: + title = "Never Allow" + } + var peerIds = Set() + updateState { state in + peerIds = state.disableFor + return state + } + pushControllerImpl?(selectivePrivacyPeersController(account: account, title: title, initialPeerIds: Array(peerIds), updated: { updatedPeerIds in + updateState { state in + return state.withUpdatedDisableFor(Set(updatedPeerIds)).withUpdatedEnableFor(state.enableFor.subtracting(Set(updatedPeerIds))) + } + })) + }) + + let signal = statePromise.get() |> deliverOnMainQueue + |> map { state -> (ItemListControllerState, (ItemListNodeState, SelectivePrivacySettingsEntry.ItemGenerationArguments)) in + + let leftNavigationButton = ItemListNavigationButton(title: "Cancel", style: .regular, enabled: true, action: { + dismissImpl?() + }) + + let rightNavigationButton: ItemListNavigationButton + if state.saving { + rightNavigationButton = ItemListNavigationButton(title: "", style: .activity, enabled: true, action: {}) + } else { + rightNavigationButton = ItemListNavigationButton(title: "Done", style: .bold, enabled: true, action: { + var wasSaving = false + var settings: SelectivePrivacySettings? + updateState { state in + wasSaving = state.saving + switch state.setting { + case .everybody: + settings = SelectivePrivacySettings.enableEveryone(disableFor: state.disableFor) + case .contacts: + settings = SelectivePrivacySettings.enableContacts(enableFor: state.enableFor, disableFor: state.disableFor) + case .nobody: + settings = SelectivePrivacySettings.disableEveryone(enableFor: state.enableFor) + } + return state.withUpdatedSaving(true) + } + + if let settings = settings, !wasSaving { + let type: UpdateSelectiveAccountPrivacySettingsType + switch kind { + case .presence: + type = .presence + case .groupInvitations: + type = .groupInvitations + case .voiceCalls: + type = .voiceCalls + } + + updateSettingsDisposable.set((updateSelectiveAccountPrivacySettings(account: account, type: type, settings: settings) |> deliverOnMainQueue).start(completed: { + updateState { state in + return state.withUpdatedSaving(false) + } + updated(settings) + dismissImpl?() + })) + } + }) + } + + let title: String + switch kind { + case .presence: + title = "Last Seen" + case .groupInvitations: + title = "Groups" + case .voiceCalls: + title = "Voice Calls" + } + let controllerState = ItemListControllerState(title: title, leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, animateChanges: false) + let listState = ItemListNodeState(entries: selectivePrivacySettingsControllerEntries(kind: kind, state: state), style: .blocks, animateChanges: false) + + return (controllerState, (listState, arguments)) + } |> afterDisposed { + actionsDisposable.dispose() + } + + let controller = ItemListController(signal) + controller.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + pushControllerImpl = { [weak controller] c in + (controller?.navigationController as? NavigationController)?.pushViewController(c) + } + presentControllerImpl = { [weak controller] c in + controller?.present(c, in: .window) + } + dismissImpl = { [weak controller] in + (controller?.navigationController as? NavigationController)?.popViewController(animated: true) + } + + return controller +} diff --git a/TelegramUI/SelectivePrivacySettingsPeersController.swift b/TelegramUI/SelectivePrivacySettingsPeersController.swift new file mode 100644 index 0000000000..f80ca047d4 --- /dev/null +++ b/TelegramUI/SelectivePrivacySettingsPeersController.swift @@ -0,0 +1,314 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private let addMemberPlusIcon = UIImage(bundleImageName: "Peer Info/PeerItemPlusIcon")?.precomposed() + +private final class SelectivePrivacyPeersControllerArguments { + let account: Account + + let setPeerIdWithRevealedOptions: (PeerId?, PeerId?) -> Void + let removePeer: (PeerId) -> Void + let addPeer: () -> Void + + init(account: Account, setPeerIdWithRevealedOptions: @escaping (PeerId?, PeerId?) -> Void, removePeer: @escaping (PeerId) -> Void, addPeer: @escaping () -> Void) { + self.account = account + self.setPeerIdWithRevealedOptions = setPeerIdWithRevealedOptions + self.removePeer = removePeer + self.addPeer = addPeer + } +} + +private enum SelectivePrivacyPeersSection: Int32 { + case peers + case add +} + +private enum SelectivePrivacyPeersEntryStableId: Hashable { + case peer(PeerId) + case add + + var hashValue: Int { + switch self { + case let .peer(peerId): + return peerId.hashValue + case .add: + return 1 + } + } + + static func ==(lhs: SelectivePrivacyPeersEntryStableId, rhs: SelectivePrivacyPeersEntryStableId) -> Bool { + switch lhs { + case let .peer(peerId): + if case .peer(peerId) = rhs { + return true + } else { + return false + } + case .add: + if case .add = rhs { + return true + } else { + return false + } + } + } +} + +private enum SelectivePrivacyPeersEntry: ItemListNodeEntry { + case peerItem(Int32, Peer, ItemListPeerItemEditing, Bool) + case addItem(Bool) + + var section: ItemListSectionId { + switch self { + case .peerItem: + return SelectivePrivacyPeersSection.peers.rawValue + case .addItem: + return SelectivePrivacyPeersSection.add.rawValue + } + } + + var stableId: SelectivePrivacyPeersEntryStableId { + switch self { + case let .peerItem(_, peer, _, _): + return .peer(peer.id) + case .addItem: + return .add + } + } + + static func ==(lhs: SelectivePrivacyPeersEntry, rhs: SelectivePrivacyPeersEntry) -> Bool { + switch lhs { + case let .peerItem(lhsIndex, lhsPeer, lhsEditing, lhsEnabled): + if case let .peerItem(rhsIndex, rhsPeer, rhsEditing, rhsEnabled) = rhs { + if lhsIndex != rhsIndex { + return false + } + if !lhsPeer.isEqual(rhsPeer) { + return false + } + if lhsEditing != rhsEditing { + return false + } + if lhsEnabled != rhsEnabled { + return false + } + return true + } else { + return false + } + case let .addItem(editing): + if case .addItem(editing) = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: SelectivePrivacyPeersEntry, rhs: SelectivePrivacyPeersEntry) -> Bool { + switch lhs { + case let .peerItem(index, _, _, _): + switch rhs { + case let .peerItem(rhsIndex, _, _, _): + return index < rhsIndex + case .addItem: + return true + } + case .addItem: + return false + } + } + + func item(_ arguments: SelectivePrivacyPeersControllerArguments) -> ListViewItem { + switch self { + case let .peerItem(_, peer, editing, enabled): + return ItemListPeerItem(account: arguments.account, peer: peer, presence: nil, text: .none, label: nil, editing: editing, switchValue: nil, enabled: enabled, sectionId: self.section, action: nil, setPeerIdWithRevealedOptions: { previousId, id in + arguments.setPeerIdWithRevealedOptions(previousId, id) + }, removePeer: { peerId in + arguments.removePeer(peerId) + }) + case let .addItem(editing): + return ItemListPeerActionItem(icon: addMemberPlusIcon, title: "Add New...", sectionId: self.section, editing: editing, action: { + arguments.addPeer() + }) + } + } +} + +private struct SelectivePrivacyPeersControllerState: Equatable { + let editing: Bool + let peerIdWithRevealedOptions: PeerId? + + init() { + self.editing = false + self.peerIdWithRevealedOptions = nil + } + + init(editing: Bool, peerIdWithRevealedOptions: PeerId?) { + self.editing = editing + self.peerIdWithRevealedOptions = peerIdWithRevealedOptions + } + + static func ==(lhs: SelectivePrivacyPeersControllerState, rhs: SelectivePrivacyPeersControllerState) -> Bool { + if lhs.editing != rhs.editing { + return false + } + if lhs.peerIdWithRevealedOptions != rhs.peerIdWithRevealedOptions { + return false + } + return true + } + + func withUpdatedEditing(_ editing: Bool) -> SelectivePrivacyPeersControllerState { + return SelectivePrivacyPeersControllerState(editing: editing, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions) + } + + func withUpdatedPeerIdWithRevealedOptions(_ peerIdWithRevealedOptions: PeerId?) -> SelectivePrivacyPeersControllerState { + return SelectivePrivacyPeersControllerState(editing: self.editing, peerIdWithRevealedOptions: peerIdWithRevealedOptions) + } +} + +private func selectivePrivacyPeersControllerEntries(state: SelectivePrivacyPeersControllerState, peers: [Peer]) -> [SelectivePrivacyPeersEntry] { + var entries: [SelectivePrivacyPeersEntry] = [] + + var index: Int32 = 0 + for peer in peers { + entries.append(.peerItem(index, peer, ItemListPeerItemEditing(editable: true, editing: state.editing, revealed: peer.id == state.peerIdWithRevealedOptions), true)) + index += 1 + } + + entries.append(.addItem(state.editing)) + + return entries +} + +public func selectivePrivacyPeersController(account: Account, title: String, initialPeerIds: [PeerId], updated: @escaping ([PeerId]) -> Void) -> ViewController { + let statePromise = ValuePromise(SelectivePrivacyPeersControllerState(), ignoreRepeated: true) + let stateValue = Atomic(value: SelectivePrivacyPeersControllerState()) + let updateState: ((SelectivePrivacyPeersControllerState) -> SelectivePrivacyPeersControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)? + + let actionsDisposable = DisposableSet() + + let addPeerDisposable = MetaDisposable() + actionsDisposable.add(addPeerDisposable) + + let removePeerDisposable = MetaDisposable() + actionsDisposable.add(removePeerDisposable) + + let peersPromise = Promise<[Peer]>() + peersPromise.set(account.postbox.modify { modifier -> [Peer] in + var result: [Peer] = [] + for peerId in initialPeerIds { + if let peer = modifier.getPeer(peerId) { + result.append(peer) + } + } + return result + }) + + let arguments = SelectivePrivacyPeersControllerArguments(account: account, setPeerIdWithRevealedOptions: { peerId, fromPeerId in + updateState { state in + if (peerId == nil && fromPeerId == state.peerIdWithRevealedOptions) || (peerId != nil && fromPeerId == nil) { + return state.withUpdatedPeerIdWithRevealedOptions(peerId) + } else { + return state + } + } + }, removePeer: { memberId in + let applyPeers: Signal = peersPromise.get() + |> take(1) + |> deliverOnMainQueue + |> mapToSignal { peers -> Signal in + var updatedPeers = peers + for i in 0 ..< updatedPeers.count { + if updatedPeers[i].id == memberId { + updatedPeers.remove(at: i) + break + } + } + peersPromise.set(.single(updatedPeers)) + updated(updatedPeers.map { $0.id }) + + return .complete() + } + + removePeerDisposable.set(applyPeers.start()) + }, addPeer: { + let controller = ContactMultiselectionController(account: account, mode: .peerSelection) + addPeerDisposable.set((controller.result |> take(1) |> deliverOnMainQueue).start(next: { [weak controller] peerIds in + let applyPeers: Signal = peersPromise.get() + |> take(1) + |> mapToSignal { peers -> Signal<[Peer], NoError> in + return account.postbox.modify { modifier -> [Peer] in + var updatedPeers = peers + var existingIds = Set(updatedPeers.map { $0.id }) + for peerId in peerIds { + if let peer = modifier.getPeer(peerId), !existingIds.contains(peerId) { + existingIds.insert(peerId) + updatedPeers.append(peer) + } + } + return updatedPeers + } + } + |> deliverOnMainQueue + |> mapToSignal { updatedPeers -> Signal in + peersPromise.set(.single(updatedPeers)) + updated(updatedPeers.map { $0.id }) + return .complete() + } + + removePeerDisposable.set(applyPeers.start()) + controller?.dismiss() + })) + presentControllerImpl?(controller, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + }) + + var previousPeers: [Peer]? + + let signal = combineLatest(statePromise.get(), peersPromise.get()) + |> deliverOnMainQueue + |> map { state, peers -> (ItemListControllerState, (ItemListNodeState, SelectivePrivacyPeersEntry.ItemGenerationArguments)) in + var rightNavigationButton: ItemListNavigationButton? + if !peers.isEmpty { + if state.editing { + rightNavigationButton = ItemListNavigationButton(title: "Done", style: .bold, enabled: true, action: { + updateState { state in + return state.withUpdatedEditing(false) + } + }) + } else { + rightNavigationButton = ItemListNavigationButton(title: "Edit", style: .regular, enabled: true, action: { + updateState { state in + return state.withUpdatedEditing(true) + } + }) + } + } + + let previous = previousPeers + previousPeers = peers + + let controllerState = ItemListControllerState(title: title, leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) + let listState = ItemListNodeState(entries: selectivePrivacyPeersControllerEntries(state: state, peers: peers), style: .blocks, emptyStateItem: nil, animateChanges: previous != nil && previous!.count >= peers.count) + + return (controllerState, (listState, arguments)) + } |> afterDisposed { + actionsDisposable.dispose() + } + + let controller = ItemListController(signal) + presentControllerImpl = { [weak controller] c, p in + if let controller = controller { + controller.present(c, in: .window, with: p) + } + } + return controller +} diff --git a/TelegramUI/SettingsAccountInfoItem.swift b/TelegramUI/SettingsAccountInfoItem.swift index ab8dd4c9f9..1ce75a8aa3 100644 --- a/TelegramUI/SettingsAccountInfoItem.swift +++ b/TelegramUI/SettingsAccountInfoItem.swift @@ -112,7 +112,7 @@ class SettingsAccountInfoItemNode: ListControllerGroupableItemNode { if let strongSelf = self { strongSelf.avatarNode.setPeer(account, peer: peer) let width = strongSelf.bounds.size.width - if width > CGFloat(FLT_EPSILON) { + if width > CGFloat.ulpOfOne { strongSelf.layoutContentForWidth(width) strongSelf.nameNode.setNeedsDisplay() } @@ -124,7 +124,7 @@ class SettingsAccountInfoItemNode: ListControllerGroupableItemNode { //strongSelf.statusNode.attributedString = NSAttributedString(string: statusText, font: statusFont, textColor: statusColor) let width = strongSelf.bounds.size.width - if width > CGFloat(FLT_EPSILON) { + if width > CGFloat.ulpOfOne { strongSelf.layoutContentForWidth(width) strongSelf.statusNode.setNeedsDisplay() } diff --git a/TelegramUI/SettingsController.swift b/TelegramUI/SettingsController.swift index c7f38b076b..e89334648d 100644 --- a/TelegramUI/SettingsController.swift +++ b/TelegramUI/SettingsController.swift @@ -8,10 +8,13 @@ private struct SettingsItemArguments { let account: Account let accountManager: AccountManager + let openPrivacyAndSecurity: () -> Void let pushController: (ViewController) -> Void let presentController: (ViewController) -> Void let updateEditingName: (ItemListAvatarAndNameInfoItemName) -> Void let saveEditingState: () -> Void + let openSupport: () -> Void + let openFaq: () -> Void let logout: () -> Void } @@ -33,7 +36,7 @@ private enum SettingsEntry: ItemListNodeEntry { case stickers case phoneNumber(String) case username(String) - case askAQuestion + case askAQuestion(Bool) case faq case debug case logOut @@ -149,8 +152,8 @@ private enum SettingsEntry: ItemListNodeEntry { } else { return false } - case .askAQuestion: - if case .askAQuestion = rhs { + case let .askAQuestion(loading): + if case .askAQuestion(loading) = rhs { return true } else { return false @@ -188,14 +191,6 @@ private enum SettingsEntry: ItemListNodeEntry { }) case .setProfilePhoto: return ItemListActionItem(title: "Set Profile Photo", kind: .generic, alignment: .natural, sectionId: ItemListSectionId(self.section), style: .blocks, action: { - arguments.presentController(standardTextAlertController(title: "Verification Failed", text: "Your Apple ID or password is incorrect.", actions: [ - TextAlertAction(type: .genericAction, title: "Cancel", action: { - - }), - TextAlertAction(type: .defaultAction, title: "OK", action: { - - }) - ])) }) case .notificationsAndSounds: return ItemListDisclosureItem(title: "Notifications and Sounds", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { @@ -203,7 +198,7 @@ private enum SettingsEntry: ItemListNodeEntry { }) case .privacyAndSecurity: return ItemListDisclosureItem(title: "Privacy and Security", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { - arguments.pushController(privacyAndSecurityController(account: arguments.account)) + arguments.openPrivacyAndSecurity() }) case .dataAndStorage: return ItemListDisclosureItem(title: "Data and Storage", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { @@ -211,23 +206,23 @@ private enum SettingsEntry: ItemListNodeEntry { }) case .stickers: return ItemListDisclosureItem(title: "Stickers", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { - + arguments.pushController(installedStickerPacksController(account: arguments.account, mode: .general)) }) case let .phoneNumber(number): return ItemListDisclosureItem(title: "Phone Number", label: number, sectionId: ItemListSectionId(self.section), style: .blocks, action: { - + arguments.pushController(ChangePhoneNumberIntroController(account: arguments.account, phoneNumber: number)) }) case let .username(address): return ItemListDisclosureItem(title: "Username", label: address, sectionId: ItemListSectionId(self.section), style: .blocks, action: { arguments.presentController(usernameSetupController(account: arguments.account)) }) - case .askAQuestion: + case let .askAQuestion(askAQuestion): return ItemListDisclosureItem(title: "Ask a Question", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { - + arguments.openSupport() }) case .faq: return ItemListDisclosureItem(title: "Telegram FAQ", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { - + arguments.openFaq() }) case .debug: return ItemListDisclosureItem(title: "Debug", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { @@ -256,13 +251,18 @@ private struct SettingsEditingState: Equatable { private struct SettingsState: Equatable { let editingState: SettingsEditingState? let updatingName: ItemListAvatarAndNameInfoItemName? + let loadingSupportPeer: Bool func withUpdatedEditingState(_ editingState: SettingsEditingState?) -> SettingsState { - return SettingsState(editingState: editingState, updatingName: self.updatingName) + return SettingsState(editingState: editingState, updatingName: self.updatingName, loadingSupportPeer: self.loadingSupportPeer) } func withUpdatedUpdatingName(_ updatingName: ItemListAvatarAndNameInfoItemName?) -> SettingsState { - return SettingsState(editingState: self.editingState, updatingName: updatingName) + return SettingsState(editingState: self.editingState, updatingName: updatingName, loadingSupportPeer: self.loadingSupportPeer) + } + + func withUpdatedLoadingSupportPeer(_ loadingSupportPeer: Bool) -> SettingsState { + return SettingsState(editingState: self.editingState, updatingName: self.updatingName, loadingSupportPeer: loadingSupportPeer) } static func ==(lhs: SettingsState, rhs: SettingsState) -> Bool { @@ -272,6 +272,9 @@ private struct SettingsState: Equatable { if lhs.updatingName != rhs.updatingName { return false } + if lhs.loadingSupportPeer != rhs.loadingSupportPeer { + return false + } return true } } @@ -294,7 +297,7 @@ private func settingsEntries(state: SettingsState, view: PeerView) -> [SettingsE } entries.append(.username(peer.addressName == nil ? "" : ("@" + peer.addressName!))) - entries.append(.askAQuestion) + entries.append(.askAQuestion(state.loadingSupportPeer)) entries.append(.faq) entries.append(.debug) @@ -307,8 +310,8 @@ private func settingsEntries(state: SettingsState, view: PeerView) -> [SettingsE } public func settingsController(account: Account, accountManager: AccountManager) -> ViewController { - let statePromise = ValuePromise(SettingsState(editingState: nil, updatingName: nil), ignoreRepeated: true) - let stateValue = Atomic(value: SettingsState(editingState: nil, updatingName: nil)) + let statePromise = ValuePromise(SettingsState(editingState: nil, updatingName: nil, loadingSupportPeer: false), ignoreRepeated: true) + let stateValue = Atomic(value: SettingsState(editingState: nil, updatingName: nil, loadingSupportPeer: false)) let updateState: ((SettingsState) -> SettingsState) -> Void = { f in statePromise.set(stateValue.modify { f($0) }) } @@ -321,7 +324,15 @@ public func settingsController(account: Account, accountManager: AccountManager) let updatePeerNameDisposable = MetaDisposable() actionsDisposable.add(updatePeerNameDisposable) - let arguments = SettingsItemArguments(account: account, accountManager: accountManager, pushController: { controller in + let supportPeerDisposable = MetaDisposable() + actionsDisposable.add(supportPeerDisposable) + + //let privacySettings = Promise() + //privacySettings.set(.single(nil) |> then(requestAccountPrivacySettings(account: account) |> map { Optional($0) })) + + let arguments = SettingsItemArguments(account: account, accountManager: accountManager, openPrivacyAndSecurity: { + pushControllerImpl?(privacyAndSecurityController(account: account, initialSettings: .single(nil) |> then(requestAccountPrivacySettings(account: account) |> map { Optional($0) }))) + }, pushController: { controller in pushControllerImpl?(controller) }, presentController: { controller in presentControllerImpl?(controller) @@ -352,6 +363,33 @@ public func settingsController(account: Account, accountManager: AccountManager) } }).start()) } + }, openSupport: { + var load = false + updateState { state in + if !state.loadingSupportPeer { + load = true + } + return state.withUpdatedLoadingSupportPeer(true) + } + if load { + supportPeerDisposable.set((supportPeerId(account: account) |> deliverOnMainQueue).start(next: { peerId in + updateState { state in + return state.withUpdatedLoadingSupportPeer(false) + } + if let peerId = peerId { + pushControllerImpl?(ChatController(account: account, peerId: peerId)) + } + })) + } + }, openFaq: { + var faqUrl = NSLocalizedString("Settings.FAQ_URL", comment: "") + if faqUrl == "Settings.FAQ_URL" { + faqUrl = "http://telegram.org/faq#general" + } + + if let applicationContext = account.applicationContext as? TelegramApplicationContext { + applicationContext.openUrl(faqUrl) + } }, logout: { let alertController = standardTextAlertController(title: NSLocalizedString("Settings.LogoutConfirmationTitle", comment: ""), text: NSLocalizedString("Settings.LogoutConfirmationText", comment: ""), actions: [ TextAlertAction(type: .genericAction, title: "Cancel", action: { @@ -359,7 +397,7 @@ public func settingsController(account: Account, accountManager: AccountManager) TextAlertAction(type: .defaultAction, title: "OK", action: { let _ = logoutFromAccount(id: account.id, accountManager: accountManager).start() }) - ]) + ]) presentControllerImpl?(alertController) }) diff --git a/TelegramUI/StickerPackGalleryController.swift b/TelegramUI/StickerPackGalleryController.swift new file mode 100644 index 0000000000..fbf287572c --- /dev/null +++ b/TelegramUI/StickerPackGalleryController.swift @@ -0,0 +1,2 @@ +import Foundation + diff --git a/TelegramUI/StickerPackPreviewController.swift b/TelegramUI/StickerPackPreviewController.swift new file mode 100644 index 0000000000..51689e140b --- /dev/null +++ b/TelegramUI/StickerPackPreviewController.swift @@ -0,0 +1,77 @@ +import Foundation +import Display +import AsyncDisplayKit +import Postbox +import TelegramCore +import SwiftSignalKit + +final class StickerPackPreviewController: ViewController { + private var controllerNode: StickerPackPreviewControllerNode { + return self.displayNode as! StickerPackPreviewControllerNode + } + + private var animatedIn = false + + private let account: Account + private let stickerPack: StickerPackReference + + private let stickerPackDisposable = MetaDisposable() + private let stickerPackContents = Promise() + + private let stickerPackInstalledDisposable = MetaDisposable() + private let stickerPackInstalled = Promise() + + init(account: Account, stickerPack: StickerPackReference) { + self.account = account + self.stickerPack = stickerPack + + super.init(navigationBar: NavigationBar()) + + self.navigationBar.isHidden = true + + self.stickerPackContents.set(loadedStickerPack(account: account, reference: stickerPack)) + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.stickerPackDisposable.dispose() + self.stickerPackInstalledDisposable.dispose() + } + + override func loadDisplayNode() { + self.displayNode = StickerPackPreviewControllerNode(account: self.account) + self.controllerNode.dismiss = { [weak self] in + self?.presentingViewController?.dismiss(animated: false, completion: nil) + } + self.controllerNode.cancel = { [weak self] in + self?.dismiss() + } + self.displayNodeDidLoad() + self.stickerPackDisposable.set((self.stickerPackContents.get() |> deliverOnMainQueue).start(next: { [weak self] next in + self?.controllerNode.updateStickerPack(next) + })) + self.ready.set(self.controllerNode.ready.get()) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if !self.animatedIn { + self.animatedIn = true + self.controllerNode.animateIn() + } + } + + override func dismiss() { + self.controllerNode.animateOut() + } + + override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationHeight, transition: transition) + } +} diff --git a/TelegramUI/StickerPackPreviewControllerNode.swift b/TelegramUI/StickerPackPreviewControllerNode.swift new file mode 100644 index 0000000000..055d5cb4c5 --- /dev/null +++ b/TelegramUI/StickerPackPreviewControllerNode.swift @@ -0,0 +1,442 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore + +private let defaultBackgroundColor: UIColor = UIColor(white: 1.0, alpha: 1.0) +private let highlightedBackgroundColor: UIColor = UIColor(white: 0.9, alpha: 1.0) +private let separatorColor: UIColor = UIColor(0xbcbbc1) + +final class StickerPackPreviewControllerNode: ASDisplayNode, UIScrollViewDelegate { + private let account: Account + + private var containerLayout: (ContainerViewLayout, CGFloat)? + + private let dimNode: ASDisplayNode + + private let wrappingScrollNode: ASScrollNode + private let cancelButtonNode: HighlightTrackingButtonNode + + private let contentContainerNode: ASDisplayNode + private let contentBackgroundNode: ASDisplayNode + private let contentGridNode: GridNode + private let installActionButtonNode: HighlightTrackingButtonNode + private let installActionSeparatorNode: ASDisplayNode + private let contentTitleNode: ASTextNode + private let contentSeparatorNode: ASDisplayNode + + private var activityIndicatorView: UIActivityIndicatorView? + + var dismiss: (() -> Void)? + var cancel: (() -> Void)? + + let ready = Promise() + private var didSetReady = false + + private var stickerPack: LoadedStickerPack? + private var stickerPackUpdated = false + + private var didSetItems = false + + init(account: Account) { + self.account = account + + self.wrappingScrollNode = ASScrollNode() + self.wrappingScrollNode.view.alwaysBounceVertical = true + self.wrappingScrollNode.view.delaysContentTouches = false + self.wrappingScrollNode.view.canCancelContentTouches = true + + self.dimNode = ASDisplayNode() + self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5) + + self.cancelButtonNode = HighlightTrackingButtonNode() + self.cancelButtonNode.cornerRadius = 16.0 + self.cancelButtonNode.clipsToBounds = true + + self.contentContainerNode = ASDisplayNode() + self.contentContainerNode.cornerRadius = 16.0 + self.contentContainerNode.clipsToBounds = true + self.contentContainerNode.isOpaque = false + + self.contentBackgroundNode = ASDisplayNode() + self.contentBackgroundNode.cornerRadius = 16.0 + self.contentBackgroundNode.clipsToBounds = true + self.contentBackgroundNode.backgroundColor = defaultBackgroundColor + + self.contentGridNode = GridNode() + + self.installActionButtonNode = HighlightTrackingButtonNode() + + self.contentTitleNode = ASTextNode() + + self.contentSeparatorNode = ASDisplayNode() + self.contentSeparatorNode.isLayerBacked = true + self.contentSeparatorNode.displaysAsynchronously = false + self.contentSeparatorNode.backgroundColor = separatorColor + + self.installActionSeparatorNode = ASDisplayNode() + self.installActionSeparatorNode.isLayerBacked = true + self.installActionSeparatorNode.displaysAsynchronously = false + self.installActionSeparatorNode.backgroundColor = separatorColor + + super.init(viewBlock: { + return UITracingLayerView() + }, didLoad: nil) + + self.backgroundColor = nil + self.isOpaque = false + + self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) + self.addSubnode(self.dimNode) + + self.wrappingScrollNode.view.delegate = self + self.addSubnode(self.wrappingScrollNode) + + self.cancelButtonNode.setTitle("Cancel", with: Font.medium(20.0), with: UIColor(0x007ee5), for: .normal) + self.cancelButtonNode.backgroundColor = defaultBackgroundColor + self.cancelButtonNode.highligthedChanged = { [weak self] highlighted in + if let strongSelf = self { + if highlighted { + strongSelf.cancelButtonNode.backgroundColor = highlightedBackgroundColor + } else { + UIView.animate(withDuration: 0.3, animations: { + strongSelf.cancelButtonNode.backgroundColor = defaultBackgroundColor + }) + } + } + } + + self.installActionButtonNode.backgroundColor = defaultBackgroundColor + self.installActionButtonNode.highligthedChanged = { [weak self] highlighted in + if let strongSelf = self { + if highlighted { + strongSelf.installActionButtonNode.backgroundColor = highlightedBackgroundColor + } else { + UIView.animate(withDuration: 0.3, animations: { + strongSelf.installActionButtonNode.backgroundColor = defaultBackgroundColor + }) + } + } + } + + self.wrappingScrollNode.addSubnode(self.cancelButtonNode) + self.cancelButtonNode.addTarget(self, action: #selector(self.cancelButtonPressed), forControlEvents: .touchUpInside) + + self.installActionButtonNode.addTarget(self, action: #selector(self.installActionButtonPressed), forControlEvents: .touchUpInside) + + self.wrappingScrollNode.addSubnode(self.contentBackgroundNode) + + self.wrappingScrollNode.addSubnode(self.contentContainerNode) + self.contentContainerNode.addSubnode(self.contentGridNode) + self.contentContainerNode.addSubnode(self.installActionSeparatorNode) + self.contentContainerNode.addSubnode(self.installActionButtonNode) + self.wrappingScrollNode.addSubnode(self.contentTitleNode) + self.wrappingScrollNode.addSubnode(self.contentSeparatorNode) + + self.contentGridNode.presentationLayoutUpdated = { [weak self] presentationLayout, transition in + self?.gridPresentationLayoutUpdated(presentationLayout, transition: transition) + } + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + self.containerLayout = (layout, navigationBarHeight) + + transition.updateFrame(node: self.wrappingScrollNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + + var insets = layout.insets(options: [.statusBar]) + insets.top = max(10.0, insets.top) + + transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + + let bottomInset: CGFloat = 10.0 + let buttonHeight: CGFloat = 57.0 + let sectionSpacing: CGFloat = 8.0 + let titleAreaHeight: CGFloat = 51.0 + + let width = min(layout.size.width, layout.size.height) - 20.0 + + let sideInset = floor((layout.size.width - width) / 2.0) + + transition.updateFrame(node: self.cancelButtonNode, frame: CGRect(origin: CGPoint(x: sideInset, y: layout.size.height - bottomInset - buttonHeight), size: CGSize(width: width, height: buttonHeight))) + + let maximumContentHeight = layout.size.height - insets.top - bottomInset - buttonHeight - sectionSpacing + + let contentContainerFrame = CGRect(origin: CGPoint(x: sideInset, y: insets.top), size: CGSize(width: width, height: maximumContentHeight)) + let contentFrame = contentContainerFrame.insetBy(dx: 12.0, dy: 0.0) + + var insertItems: [GridNodeInsertItem] = [] + + var itemCount = 0 + var animateIn = false + + if let stickerPack = self.stickerPack { + switch stickerPack { + case .fetching, .none: + if self.activityIndicatorView == nil { + let activityIndicatorView = UIActivityIndicatorView(activityIndicatorStyle: .gray) + self.activityIndicatorView = activityIndicatorView + self.view.addSubview(activityIndicatorView) + activityIndicatorView.startAnimating() + } + case let .result(info, items, _): + if let activityIndicatorView = self.activityIndicatorView { + activityIndicatorView.removeFromSuperview() + activityIndicatorView.stopAnimating() + } + itemCount = items.count + if !self.didSetItems { + self.contentTitleNode.attributedText = NSAttributedString(string: info.title, font: Font.medium(20.0), textColor: .black) + + self.didSetItems = true + animateIn = true + for i in 0 ..< items.count { + insertItems.append(GridNodeInsertItem(index: i, item: StickerPackPreviewGridItem(account: self.account, stickerItem: items[i] as! StickerPackItem), previousIndex: nil)) + } + } + } + } + + //self.contentGridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: insertItems, updateItems: [], scrollToItem: nil, updateLayout: nil, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in }) + + let titleSize = self.contentTitleNode.measure(contentContainerFrame.size) + let titleFrame = CGRect(origin: CGPoint(x: contentContainerFrame.minX + floor((contentContainerFrame.size.width - titleSize.width) / 2.0), y: self.contentBackgroundNode.frame.minY + 15.0), size: titleSize) + let deltaTitlePosition = CGPoint(x: titleFrame.midX - self.contentTitleNode.frame.midX, y: titleFrame.midY - self.contentTitleNode.frame.midY) + self.contentTitleNode.frame = titleFrame + transition.animatePosition(node: self.contentTitleNode, from: CGPoint(x: titleFrame.midX + deltaTitlePosition.x, y: titleFrame.midY + deltaTitlePosition.y)) + + transition.updateFrame(node: self.contentTitleNode, frame: titleFrame) + transition.updateFrame(node: self.contentSeparatorNode, frame: CGRect(origin: CGPoint(x: contentContainerFrame.minX, y: self.contentBackgroundNode.frame.minY + titleAreaHeight), size: CGSize(width: contentContainerFrame.size.width, height: UIScreenPixel))) + + let itemsPerRow = 4 + let itemWidth = floor(contentFrame.size.width / CGFloat(itemsPerRow)) + let rowCount = itemCount / itemsPerRow + (itemCount % itemsPerRow != 0 ? 1 : 0) + + let minimallyRevealedRowCount: CGFloat = 3.5 + let initiallyRevealedRowCount = min(minimallyRevealedRowCount, CGFloat(rowCount)) + + let topInset = max(0.0, contentFrame.size.height - initiallyRevealedRowCount * itemWidth - titleAreaHeight) + let bottomGridInset = buttonHeight + + transition.updateFrame(node: self.contentContainerNode, frame: contentContainerFrame) + + if let activityIndicatorView = activityIndicatorView { + transition.updateFrame(layer: activityIndicatorView.layer, frame: CGRect(origin: CGPoint(x: contentFrame.minX + floor((contentFrame.width - activityIndicatorView.bounds.size.width) / 2.0), y: contentFrame.maxY - activityIndicatorView.bounds.size.height - 34.0), size: activityIndicatorView.bounds.size)) + } + + transition.updateFrame(node: self.installActionButtonNode, frame: CGRect(origin: CGPoint(x: 0.0, y: contentContainerFrame.size.height - buttonHeight), size: CGSize(width: contentContainerFrame.size.width, height: buttonHeight))) + transition.updateFrame(node: self.installActionSeparatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: contentContainerFrame.size.height - buttonHeight - UIScreenPixel), size: CGSize(width: contentContainerFrame.size.width, height: UIScreenPixel))) + + self.contentGridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: insertItems, updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: contentFrame.size, insets: UIEdgeInsets(top: topInset, left: 0.0, bottom: bottomGridInset, right: 0.0), preloadSize: 80.0, itemSize: CGSize(width: itemWidth, height: itemWidth)), transition: transition), stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in }) + transition.updateFrame(node: self.contentGridNode, frame: CGRect(origin: CGPoint(x: floor((contentContainerFrame.size.width - contentFrame.size.width) / 2.0), y: titleAreaHeight), size: CGSize(width: contentFrame.size.width, height: max(32.0, contentFrame.size.height - titleAreaHeight)))) + + if animateIn { + var durationOffset = 0.0 + self.contentGridNode.forEachRow { itemNodes in + for itemNode in itemNodes { + itemNode.layer.animatePosition(from: CGPoint(x: 0.0, y: 4.0), to: CGPoint(), duration: 0.4 + durationOffset, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + if let itemNode = itemNode as? StickerPackPreviewGridItemNode { + itemNode.animateIn() + } + } + durationOffset += 0.04 + } + + self.contentGridNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.installActionButtonNode.titleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.installActionSeparatorNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + let gridPosition = self.contentGridNode.layer.position + self.contentGridNode.layer.animatePosition(from: CGPoint(x: gridPosition.x, y: gridPosition.y + topInset - buttonHeight), to: gridPosition, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) + } + + if let _ = self.stickerPack, self.stickerPackUpdated { + self.dequeueUpdateStickerPack() + } + } + + private func gridPresentationLayoutUpdated(_ presentationLayout: GridNodeCurrentPresentationLayout, transition: ContainedViewLayoutTransition) { + if let (layout, _) = self.containerLayout { + var insets = layout.insets(options: [.statusBar]) + insets.top = max(10.0, insets.top) + + let bottomInset: CGFloat = 10.0 + let buttonHeight: CGFloat = 57.0 + let sectionSpacing: CGFloat = 8.0 + let titleAreaHeight: CGFloat = 51.0 + + let width = min(layout.size.width, layout.size.height) - 20.0 + + let sideInset = floor((layout.size.width - width) / 2.0) + + let maximumContentHeight = layout.size.height - insets.top - bottomInset - buttonHeight - sectionSpacing + let contentFrame = CGRect(origin: CGPoint(x: sideInset, y: insets.top), size: CGSize(width: width, height: maximumContentHeight)) + + var backgroundFrame = CGRect(origin: CGPoint(x: contentFrame.minX, y: contentFrame.minY - presentationLayout.contentOffset.y), size: contentFrame.size) + if backgroundFrame.minY < contentFrame.minY { + backgroundFrame.origin.y = contentFrame.minY + } + if backgroundFrame.maxY > contentFrame.maxY { + backgroundFrame.size.height += contentFrame.maxY - backgroundFrame.maxY + } + if backgroundFrame.size.height < buttonHeight + 32.0 { + backgroundFrame.origin.y -= buttonHeight + 32.0 - backgroundFrame.size.height + backgroundFrame.size.height = buttonHeight + 32.0 + } + var compactFrame = true + if let stickerPack = self.stickerPack, case .result = stickerPack { + compactFrame = false + } + if compactFrame { + backgroundFrame = CGRect(origin: CGPoint(x: contentFrame.minX, y: contentFrame.maxY - buttonHeight - 32.0), size: CGSize(width: contentFrame.size.width, height: buttonHeight + 32.0)) + } + transition.updateFrame(node: self.contentBackgroundNode, frame: backgroundFrame) + + let titleSize = self.contentTitleNode.bounds.size + let titleFrame = CGRect(origin: CGPoint(x: contentFrame.minX + floor((contentFrame.size.width - titleSize.width) / 2.0), y: backgroundFrame.minY + 15.0), size: titleSize) + transition.updateFrame(node: self.contentTitleNode, frame: titleFrame) + + transition.updateFrame(node: self.contentSeparatorNode, frame: CGRect(origin: CGPoint(x: contentFrame.minX, y: backgroundFrame.minY + titleAreaHeight), size: CGSize(width: contentFrame.size.width, height: UIScreenPixel))) + + if !compactFrame && CGFloat(0.0).isLessThanOrEqualTo(presentationLayout.contentOffset.y) { + self.contentSeparatorNode.alpha = 1.0 + } else { + self.contentSeparatorNode.alpha = 0.0 + } + } + } + + @objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.cancelButtonPressed() + } + } + + @objc func cancelButtonPressed() { + self.cancel?() + } + + @objc func installActionButtonPressed() { + if let stickerPack = self.stickerPack { + switch stickerPack { + case let .result(info, items, installed): + if installed { + let _ = removeStickerPackInteractively(postbox: self.account.postbox, id: info.id).start() + } else { + let _ = addStickerPackInteractively(postbox: self.account.postbox, info: info, items: items).start() + self.cancelButtonPressed() + } + default: + break + } + } + } + + func animateIn() { + self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) + + let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY + + let dimPosition = self.dimNode.layer.position + self.dimNode.layer.animatePosition(from: CGPoint(x: dimPosition.x, y: dimPosition.y - offset), to: dimPosition, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + self.layer.animateBoundsOriginYAdditive(from: -offset, to: 0.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + } + + func animateOut() { + var dimCompleted = false + var offsetCompleted = false + + let completion: () -> Void = { [weak self] in + if let strongSelf = self, dimCompleted && offsetCompleted { + strongSelf.dismiss?() + } + } + + self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in + dimCompleted = true + completion() + }) + + let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY + let dimPosition = self.dimNode.layer.position + self.dimNode.layer.animatePosition(from: dimPosition, to: CGPoint(x: dimPosition.x, y: dimPosition.y - offset), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + self.layer.animateBoundsOriginYAdditive(from: 0.0, to: -offset, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in + offsetCompleted = true + completion() + }) + } + + func updateStickerPack(_ stickerPack: LoadedStickerPack) { + self.stickerPack = stickerPack + self.stickerPackUpdated = true + if let _ = self.containerLayout { + self.dequeueUpdateStickerPack() + } + switch stickerPack { + case .none, .fetching: + self.installActionSeparatorNode.alpha = 0.0 + self.installActionButtonNode.setTitle("", with: Font.medium(20.0), with: UIColor(0x007ee5), for: .normal) + case let .result(info, _, installed): + self.installActionSeparatorNode.alpha = 1.0 + if installed { + let text: String + if info.id.namespace == Namespaces.ItemCollection.CloudStickerPacks { + text = "Remove \(info.count) stickers" + } else { + text = "Remove \(info.count) masks" + } + self.installActionButtonNode.setTitle(text, with: Font.regular(20.0), with: UIColor(0xff3b30), for: .normal) + } else { + let text: String + if info.id.namespace == Namespaces.ItemCollection.CloudStickerPacks { + text = "Add \(info.count) stickers" + } else { + text = "Add \(info.count) masks" + } + self.installActionButtonNode.setTitle(text, with: Font.regular(20.0), with: UIColor(0x007ee5), for: .normal) + } + } + } + + func dequeueUpdateStickerPack() { + if let (layout, navigationBarHeight) = self.containerLayout, let _ = stickerPack, self.stickerPackUpdated { + self.stickerPackUpdated = false + + let transition: ContainedViewLayoutTransition + if self.didSetReady { + transition = .animated(duration: 0.4, curve: .spring) + } else { + transition = .immediate + } + self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) + + if !self.didSetReady { + self.didSetReady = true + self.ready.set(.single(true)) + } + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if let result = self.installActionButtonNode.hitTest(self.installActionButtonNode.convert(point, from: self), with: event) { + return result + } + return super.hitTest(point, with: event) + } + + /*func scrollViewDidScroll(_ scrollView: UIScrollView) { + let contentOffset = scrollView.contentOffset + let additionalTopHeight = -contentOffset.y + + scrollView.alwaysBounceVertical = additionalTopHeight >= 0.0 + }*/ + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + let contentOffset = scrollView.contentOffset + let additionalTopHeight = max(0.0, -contentOffset.y) + + if additionalTopHeight >= 30.0 { + self.cancelButtonPressed() + } + } +} diff --git a/TelegramUI/StickerPackPreviewGridItem.swift b/TelegramUI/StickerPackPreviewGridItem.swift new file mode 100644 index 0000000000..e671300ee2 --- /dev/null +++ b/TelegramUI/StickerPackPreviewGridItem.swift @@ -0,0 +1,131 @@ +import Foundation +import Display +import TelegramCore +import SwiftSignalKit +import AsyncDisplayKit +import Postbox + +final class StickerPackPreviewGridItem: GridItem { + let account: Account + let stickerItem: StickerPackItem + + let section: GridSection? = nil + + init(account: Account, stickerItem: StickerPackItem) { + self.account = account + self.stickerItem = stickerItem + } + + func node(layout: GridNodeLayout) -> GridItemNode { + let node = StickerPackPreviewGridItemNode() + node.setup(account: self.account, stickerItem: self.stickerItem) + return node + } + + func update(node: GridItemNode) { + guard let node = node as? StickerPackPreviewGridItemNode else { + assertionFailure() + return + } + node.setup(account: self.account, stickerItem: self.stickerItem) + } +} + +final class StickerPackPreviewGridItemNode: GridItemNode { + private var currentState: (Account, StickerPackItem, CGSize)? + private let imageNode: TransformImageNode + private let textNode: ASTextNode + + private let stickerFetchedDisposable = MetaDisposable() + + var interfaceInteraction: ChatControllerInteraction? + var inputNodeInteraction: ChatMediaInputNodeInteraction? + var selected: (() -> Void)? + + override init() { + self.imageNode = TransformImageNode() + //self.imageNode.alphaTransitionOnFirstUpdate = true + self.imageNode.isLayerBacked = true + + self.textNode = ASTextNode() + self.textNode.isLayerBacked = true + self.textNode.displaysAsynchronously = true + + super.init() + + self.addSubnode(self.imageNode) + self.addSubnode(self.textNode) + } + + deinit { + stickerFetchedDisposable.dispose() + } + + override func didLoad() { + super.didLoad() + + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.imageNodeTap(_:)))) + } + + func setup(account: Account, stickerItem: StickerPackItem) { + if self.currentState == nil || self.currentState!.0 !== account || self.currentState!.1 != stickerItem { + var text = "" + for attribute in stickerItem.file.attributes { + if case let .Sticker(displayText, _) = attribute { + text = displayText + break + } + } + self.textNode.attributedText = NSAttributedString(string: text, font: Font.regular(20.0), textColor: .black, paragraphAlignment: .right) + if let dimensions = stickerItem.file.dimensions { + self.imageNode.setSignal(account: account, signal: chatMessageSticker(account: account, file: stickerItem.file, small: true)) + self.stickerFetchedDisposable.set(fileInteractiveFetched(account: account, file: stickerItem.file).start()) + + self.currentState = (account, stickerItem, dimensions) + self.setNeedsLayout() + } + } + + //self.updateSelectionState(animated: false) + //self.updateHiddenMedia() + } + + override func layout() { + super.layout() + + let bounds = self.bounds + let boundsSide = min(bounds.size.width - 14.0, bounds.size.height - 14.0) + let boundingSize = CGSize(width: boundsSide, height: boundsSide) + + if let (_, _, mediaDimensions) = self.currentState { + let imageSize = mediaDimensions.aspectFitted(boundingSize) + self.imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets()))() + self.imageNode.frame = CGRect(origin: CGPoint(x: floor((bounds.size.width - imageSize.width) / 2.0), y: (bounds.size.height - imageSize.height) / 2.0), size: imageSize) + let boundingFrame = CGRect(origin: CGPoint(x: floor((bounds.size.width - boundingSize.width) / 2.0), y: (bounds.size.height - boundingSize.height) / 2.0), size: boundingSize) + let textSize = CGSize(width: 32.0, height: 24.0) + self.textNode.frame = CGRect(origin: CGPoint(x: boundingFrame.maxX - 1.0 - textSize.width, y: boundingFrame.height + 10.0 - textSize.height), size: textSize) + } + } + + /*func transitionNode(id: MessageId, media: Media) -> ASDisplayNode? { + if self.messageId == id { + return self.imageNode + } else { + return nil + } + }*/ + + @objc func imageNodeTap(_ recognizer: UITapGestureRecognizer) { + if let interfaceInteraction = self.interfaceInteraction, let (_, item, _) = self.currentState, case .ended = recognizer.state { + interfaceInteraction.sendSticker(item.file) + } + /*if let controllerInteraction = self.controllerInteraction, let messageId = self.messageId, case .ended = recognizer.state { + controllerInteraction.openMessage(messageId) + }*/ + } + + func animateIn() { + self.textNode.layer.animatePosition(from: CGPoint(x: 0.0, y: 60.0), to: CGPoint(), duration: 0.42, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + } +} + diff --git a/TelegramUI/TransformImageNode.swift b/TelegramUI/TransformImageNode.swift index a4ce07955e..c34e5d52e2 100644 --- a/TelegramUI/TransformImageNode.swift +++ b/TelegramUI/TransformImageNode.swift @@ -91,4 +91,19 @@ public class TransformImageNode: ASDisplayNode { } } } + + public class func asyncLayout(_ maybeNode: TransformImageNode?) -> (TransformImageArguments) -> (() -> TransformImageNode) { + return { arguments in + let node: TransformImageNode + if let maybeNode = maybeNode { + node = maybeNode + } else { + node = TransformImageNode() + } + return { + node.argumentsPromise.set(arguments) + return node + } + } + } } diff --git a/TelegramUI/TwoStepVerificationPasswordEntryController.swift b/TelegramUI/TwoStepVerificationPasswordEntryController.swift new file mode 100644 index 0000000000..09bfe4dd33 --- /dev/null +++ b/TelegramUI/TwoStepVerificationPasswordEntryController.swift @@ -0,0 +1,437 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private final class TwoStepVerificationPasswordEntryControllerArguments { + let updateEntryText: (String) -> Void + let next: () -> Void + + init(updateEntryText: @escaping (String) -> Void, next: @escaping () -> Void) { + self.updateEntryText = updateEntryText + self.next = next + } +} + +private enum TwoStepVerificationPasswordEntrySection: Int32 { + case password +} + +private enum TwoStepVerificationPasswordEntryTag: ItemListItemTag { + case input + + func isEqual(to other: ItemListItemTag) -> Bool { + if let other = other as? TwoStepVerificationPasswordEntryTag { + switch self { + case .input: + if case .input = other { + return true + } else { + return false + } + } + } else { + return false + } + } +} + +private enum TwoStepVerificationPasswordEntryEntry: ItemListNodeEntry { + case passwordEntryTitle(String) + case passwordEntry(String) + + case hintTitle(String) + case hintEntry(String) + + case emailEntry(String) + case emailInfo(String) + + var section: ItemListSectionId { + return TwoStepVerificationPasswordEntrySection.password.rawValue + } + + var stableId: Int32 { + switch self { + case .passwordEntryTitle: + return 0 + case .passwordEntry: + return 1 + case .hintTitle: + return 2 + case .hintEntry: + return 3 + case .emailEntry: + return 5 + case .emailInfo: + return 6 + } + } + + static func ==(lhs: TwoStepVerificationPasswordEntryEntry, rhs: TwoStepVerificationPasswordEntryEntry) -> Bool { + switch lhs { + case let .passwordEntryTitle(text): + if case .passwordEntryTitle(text) = rhs { + return true + } else { + return false + } + case let .passwordEntry(text): + if case .passwordEntry(text) = rhs { + return true + } else { + return false + } + case let .hintTitle(text): + if case .hintTitle(text) = rhs { + return true + } else { + return false + } + case let .hintEntry(text): + if case .hintEntry(text) = rhs { + return true + } else { + return false + } + case let .emailEntry(text): + if case .emailEntry(text) = rhs { + return true + } else { + return false + } + case let .emailInfo(text): + if case .emailInfo(text) = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: TwoStepVerificationPasswordEntryEntry, rhs: TwoStepVerificationPasswordEntryEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(_ arguments: TwoStepVerificationPasswordEntryControllerArguments) -> ListViewItem { + switch self { + case let .passwordEntryTitle(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + case let .passwordEntry(text): + return ItemListSingleLineInputItem(title: NSAttributedString(string: "", textColor: .black), text: text, placeholder: "", type: .password, spacing: 0.0, tag: TwoStepVerificationPasswordEntryTag.input, sectionId: self.section, textUpdated: { updatedText in + arguments.updateEntryText(updatedText) + }, action: { + arguments.next() + }) + case let .hintTitle(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + case let .hintEntry(text): + return ItemListSingleLineInputItem(title: NSAttributedString(string: "", textColor: .black), text: text, placeholder: "", type: .password, spacing: 0.0, tag: TwoStepVerificationPasswordEntryTag.input, sectionId: self.section, textUpdated: { updatedText in + arguments.updateEntryText(updatedText) + }, action: { + arguments.next() + }) + case let .emailEntry(text): + return ItemListSingleLineInputItem(title: NSAttributedString(string: "E-Mail", textColor: .black), text: text, placeholder: "", type: .email, spacing: 10.0, tag: TwoStepVerificationPasswordEntryTag.input, sectionId: self.section, textUpdated: { updatedText in + arguments.updateEntryText(updatedText) + }, action: { + arguments.next() + }) + case let .emailInfo(text): + return ItemListTextItem(text: .plain(text), sectionId: self.section) + } + } +} + +private enum PasswordEntryStage: Equatable { + case entry(text: String) + case reentry(first: String, text: String) + case hint(password: String, text: String) + case email(password: String, hint: String, text: String) + + func updateCurrentText(_ text: String) -> PasswordEntryStage { + switch self { + case .entry: + return .entry(text: text) + case let .reentry(first, _): + return .reentry(first: first, text: text) + case let .hint(password, _): + return .hint(password: password, text: text) + case let .email(password, hint, _): + return .email(password: password, hint: hint, text: text) + } + } + + static func ==(lhs: PasswordEntryStage, rhs: PasswordEntryStage) -> Bool { + switch lhs { + case let .entry(text): + if case .entry(text) = rhs { + return true + } else { + return false + } + case let .reentry(first, text): + if case .reentry(first, text) = rhs { + return true + } else { + return false + } + case let .hint(password, text): + if case .hint(password, text) = rhs { + return true + } else { + return false + } + case let .email(password, hint, text): + if case .email(password, hint, text) = rhs { + return true + } else { + return false + } + } + } +} + +private struct TwoStepVerificationPasswordEntryControllerState: Equatable { + let stage: PasswordEntryStage + let updating: Bool + + init(stage: PasswordEntryStage, updating: Bool) { + self.stage = stage + self.updating = updating + } + + static func ==(lhs: TwoStepVerificationPasswordEntryControllerState, rhs: TwoStepVerificationPasswordEntryControllerState) -> Bool { + if lhs.stage != rhs.stage { + return false + } + if lhs.updating != rhs.updating { + return false + } + + return true + } + + func withUpdatedStage(_ stage: PasswordEntryStage) -> TwoStepVerificationPasswordEntryControllerState { + return TwoStepVerificationPasswordEntryControllerState(stage: stage, updating: self.updating) + } + + func withUpdatedUpdating(_ updating: Bool) -> TwoStepVerificationPasswordEntryControllerState { + return TwoStepVerificationPasswordEntryControllerState(stage: self.stage, updating: updating) + } +} + +private func twoStepVerificationPasswordEntryControllerEntries(state: TwoStepVerificationPasswordEntryControllerState, mode: TwoStepVerificationPasswordEntryMode) -> [TwoStepVerificationPasswordEntryEntry] { + var entries: [TwoStepVerificationPasswordEntryEntry] = [] + + switch state.stage { + case let .entry(text): + entries.append(.passwordEntryTitle("Enter a password")) + entries.append(.passwordEntry(text)) + case let .reentry(_, text): + entries.append(.passwordEntryTitle("Please re-enter your password")) + entries.append(.passwordEntry(text)) + case let .hint(_, text): + entries.append(.hintTitle("Please create a hint for your password")) + entries.append(.hintEntry(text)) + case let .email(_, _, text): + entries.append(.emailEntry(text)) + entries.append(.emailInfo("Please add your valid e-mail. It is the only way to recover a forgotten password.")) + } + + return entries +} + +enum TwoStepVerificationPasswordEntryMode { + case setup + case change(current: String) + case setupEmail(password: String) +} + +struct TwoStepVerificationPasswordEntryResult { + let password: String + let pendingEmailPattern: String? +} + +func twoStepVerificationPasswordEntryController(account: Account, mode: TwoStepVerificationPasswordEntryMode, result: Promise) -> ViewController { + let initialStage: PasswordEntryStage + switch mode { + case .setup, .change: + initialStage = .entry(text: "") + case .setupEmail: + initialStage = .email(password: "", hint: "", text: "") + } + let initialState = TwoStepVerificationPasswordEntryControllerState(stage: initialStage, updating: false) + + let statePromise = ValuePromise(initialState, ignoreRepeated: true) + let stateValue = Atomic(value: initialState) + let updateState: ((TwoStepVerificationPasswordEntryControllerState) -> TwoStepVerificationPasswordEntryControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var dismissImpl: (() -> Void)? + var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments) -> Void)? + + let actionsDisposable = DisposableSet() + + let updatePasswordDisposable = MetaDisposable() + actionsDisposable.add(updatePasswordDisposable) + + let checkPassword: () -> Void = { + var passwordHintEmail: (String, String, String)? + var invalidReentry = false + updateState { state in + if state.updating { + return state + } else { + switch state.stage { + case let .entry(text): + if text.isEmpty { + return state + } else { + return state.withUpdatedStage(.reentry(first: text, text: "")) + } + case let .reentry(first, text): + if text.isEmpty { + return state + } else if text != first { + invalidReentry = true + return state.withUpdatedStage(.entry(text: "")) + } else { + return state.withUpdatedStage(.hint(password: text, text: "")) + } + case let .hint(password, text): + switch mode { + case .setup: + return state.withUpdatedStage(.email(password: password, hint: text, text: "")) + case .change: + passwordHintEmail = (password, text, "") + return state.withUpdatedUpdating(true) + case .setupEmail: + preconditionFailure() + } + case let .email(password, hint, text): + passwordHintEmail = (password, hint, text) + return state.withUpdatedUpdating(true) + } + } + } + if let (password, hint, email) = passwordHintEmail { + switch mode { + case .setup, .change: + var currentPassword: String? + if case let .change(current) = mode { + currentPassword = current + } + updatePasswordDisposable.set((updateTwoStepVerificationPassword(account: account, currentPassword: currentPassword, updatedPassword: .password(password: password, hint: hint, email: email)) |> deliverOnMainQueue).start(next: { update in + updateState { + $0.withUpdatedUpdating(false) + } + switch update { + case let .password(password, pendingEmailPattern): + result.set(.single(TwoStepVerificationPasswordEntryResult(password: password, pendingEmailPattern: pendingEmailPattern))) + case .none: + break + } + }, error: { error in + updateState { + $0.withUpdatedUpdating(false) + } + let alertText: String + switch error { + case .generic: + alertText = "An error occurred." + case .invalidEmail: + alertText = "Please enter valid e-mail address." + } + presentControllerImpl?(standardTextAlertController(title: nil, text: alertText, actions: [TextAlertAction(type: .defaultAction, title: "OK", action: {})]), ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + })) + case let .setupEmail(password): + updatePasswordDisposable.set((updateTwoStepVerificationEmail(account: account, currentPassword: password, updatedEmail: email) |> deliverOnMainQueue).start(next: { update in + updateState { + $0.withUpdatedUpdating(false) + } + switch update { + case let .password(password, pendingEmailPattern): + result.set(.single(TwoStepVerificationPasswordEntryResult(password: password, pendingEmailPattern: pendingEmailPattern))) + case .none: + break + } + }, error: { error in + updateState { + $0.withUpdatedUpdating(false) + } + let alertText: String + switch error { + case .generic: + alertText = "An error occurred." + case .invalidEmail: + alertText = "Please enter valid e-mail address." + } + presentControllerImpl?(standardTextAlertController(title: nil, text: alertText, actions: [TextAlertAction(type: .defaultAction, title: "OK", action: {})]), ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + })) + } + } else if invalidReentry { + presentControllerImpl?(standardTextAlertController(title: nil, text: "Passwords don't match.\nPlease try again.", actions: [TextAlertAction(type: .defaultAction, title: "OK", action: {})]), ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + } + } + + let arguments = TwoStepVerificationPasswordEntryControllerArguments(updateEntryText: { updatedText in + updateState { + $0.withUpdatedStage($0.stage.updateCurrentText(updatedText)) + } + }, next: { + checkPassword() + }) + + let signal = statePromise.get() |> deliverOnMainQueue + |> map { state -> (ItemListControllerState, (ItemListNodeState, TwoStepVerificationPasswordEntryEntry.ItemGenerationArguments)) in + + let leftNavigationButton = ItemListNavigationButton(title: "Cancel", style: .regular, enabled: true, action: { + dismissImpl?() + }) + + var rightNavigationButton: ItemListNavigationButton? + if state.updating { + rightNavigationButton = ItemListNavigationButton(title: "", style: .activity, enabled: true, action: {}) + } else { + var nextEnabled = true + switch state.stage { + case let .entry(text): + if text.isEmpty { + nextEnabled = false + } + case let.reentry(_, text): + if text.isEmpty { + nextEnabled = false + } + case .hint, .email: + break + } + rightNavigationButton = ItemListNavigationButton(title: "Next", style: .bold, enabled: nextEnabled, action: { + checkPassword() + }) + } + + let controllerState = ItemListControllerState(title: "Password", leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, animateChanges: false) + let listState = ItemListNodeState(entries: twoStepVerificationPasswordEntryControllerEntries(state: state, mode: mode), style: .blocks, focusItemTag: TwoStepVerificationPasswordEntryTag.input, emptyStateItem: nil, animateChanges: false) + + return (controllerState, (listState, arguments)) + } |> afterDisposed { + actionsDisposable.dispose() + } + + let controller = ItemListController(signal) + controller.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + + presentControllerImpl = { [weak controller] c, p in + if let controller = controller { + controller.present(c, in: .window, with: p) + } + } + dismissImpl = { [weak controller] in + controller?.dismiss() + } + + return controller +} diff --git a/TelegramUI/TwoStepVerificationResetController.swift b/TelegramUI/TwoStepVerificationResetController.swift new file mode 100644 index 0000000000..06e87f981d --- /dev/null +++ b/TelegramUI/TwoStepVerificationResetController.swift @@ -0,0 +1,236 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private final class TwoStepVerificationResetControllerArguments { + let updateEntryText: (String) -> Void + let next: () -> Void + let openEmailInaccessible: () -> Void + + init(updateEntryText: @escaping (String) -> Void, next: @escaping () -> Void, openEmailInaccessible: @escaping () -> Void) { + self.updateEntryText = updateEntryText + self.next = next + self.openEmailInaccessible = openEmailInaccessible + } +} + +private enum TwoStepVerificationResetSection: Int32 { + case password +} + +private enum TwoStepVerificationResetTag: ItemListItemTag { + case input + + func isEqual(to other: ItemListItemTag) -> Bool { + if let other = other as? TwoStepVerificationResetTag { + switch self { + case .input: + if case .input = other { + return true + } else { + return false + } + } + } else { + return false + } + } +} + +private enum TwoStepVerificationResetEntry: ItemListNodeEntry { + case codeEntry(String) + case codeInfo(String) + + var section: ItemListSectionId { + return TwoStepVerificationResetSection.password.rawValue + } + + var stableId: Int32 { + switch self { + case .codeEntry: + return 0 + case .codeInfo: + return 1 + } + } + + static func ==(lhs: TwoStepVerificationResetEntry, rhs: TwoStepVerificationResetEntry) -> Bool { + switch lhs { + case let .codeEntry(text): + if case .codeEntry(text) = rhs { + return true + } else { + return false + } + case let .codeInfo(text): + if case .codeInfo(text) = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: TwoStepVerificationResetEntry, rhs: TwoStepVerificationResetEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(_ arguments: TwoStepVerificationResetControllerArguments) -> ListViewItem { + switch self { + case let .codeEntry(text): + return ItemListSingleLineInputItem(title: NSAttributedString(string: "Code", textColor: .black), text: text, placeholder: "", type: .password, spacing: 10.0, tag: TwoStepVerificationResetTag.input, sectionId: self.section, textUpdated: { updatedText in + arguments.updateEntryText(updatedText) + }, action: { + arguments.next() + }) + case let .codeInfo(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + } + } +} + +private struct TwoStepVerificationResetControllerState: Equatable { + let codeText: String + let checking: Bool + + init(codeText: String, checking: Bool) { + self.codeText = codeText + self.checking = checking + } + + static func ==(lhs: TwoStepVerificationResetControllerState, rhs: TwoStepVerificationResetControllerState) -> Bool { + if lhs.codeText != rhs.codeText { + return false + } + if lhs.checking != rhs.checking { + return false + } + + return true + } + + func withUpdatedCodeText(_ codeText: String) -> TwoStepVerificationResetControllerState { + return TwoStepVerificationResetControllerState(codeText: codeText, checking: self.checking) + } + + func withUpdatedChecking(_ checking: Bool) -> TwoStepVerificationResetControllerState { + return TwoStepVerificationResetControllerState(codeText: self.codeText, checking: checking) + } +} + +private func twoStepVerificationResetControllerEntries(state: TwoStepVerificationResetControllerState, emailPattern: String) -> [TwoStepVerificationResetEntry] { + var entries: [TwoStepVerificationResetEntry] = [] + + entries.append(.codeEntry(state.codeText)) + entries.append(.codeInfo("Please check your e-mail and enter the 6-digit code we've sent there to deactivate your cloud password.\n\n[Having trouble accessing your e-mail \(escapedPlaintextForMarkdown(emailPattern))?]()")) + + return entries +} + +func twoStepVerificationResetController(account: Account, emailPattern: String, result: Promise) -> ViewController { + let initialState = TwoStepVerificationResetControllerState(codeText: "", checking: false) + + let statePromise = ValuePromise(initialState, ignoreRepeated: true) + let stateValue = Atomic(value: initialState) + let updateState: ((TwoStepVerificationResetControllerState) -> TwoStepVerificationResetControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var dismissImpl: (() -> Void)? + var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments) -> Void)? + + let actionsDisposable = DisposableSet() + + let resetPasswordDisposable = MetaDisposable() + actionsDisposable.add(resetPasswordDisposable) + + let checkCode: () -> Void = { + var code: String? + updateState { state in + if state.checking || state.codeText.isEmpty { + return state + } else { + code = state.codeText + return state.withUpdatedChecking(true) + } + } + if let code = code { + resetPasswordDisposable.set((recoverTwoStepVerificationPassword(account: account, code: code) |> deliverOnMainQueue).start(error: { error in + updateState { + return $0.withUpdatedChecking(false) + } + let alertText: String + switch error { + case .generic: + alertText = "An error occurred." + case .invalidCode: + alertText = "Invalid code. Please try again." + case .codeExpired: + alertText = "Code expired." + case .limitExceeded: + alertText = "You have entered invalid code too many times. Please try again later." + } + presentControllerImpl?(standardTextAlertController(title: nil, text: alertText, actions: [TextAlertAction(type: .defaultAction, title: "OK", action: {})]), ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + }, completed: { + updateState { + return $0.withUpdatedChecking(false) + } + result.set(.single(true)) + })) + } + } + + let arguments = TwoStepVerificationResetControllerArguments(updateEntryText: { updatedText in + updateState { + $0.withUpdatedCodeText(updatedText) + } + }, next: { + checkCode() + }, openEmailInaccessible: { + presentControllerImpl?(standardTextAlertController(title: nil, text: "Your remaining options are either to remember your password or to reset your account.", actions: [TextAlertAction(type: .defaultAction, title: "OK", action: {})]), ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + }) + + let signal = statePromise.get() |> deliverOnMainQueue + |> map { state -> (ItemListControllerState, (ItemListNodeState, TwoStepVerificationResetEntry.ItemGenerationArguments)) in + + let leftNavigationButton = ItemListNavigationButton(title: "Cancel", style: .regular, enabled: true, action: { + dismissImpl?() + }) + + var rightNavigationButton: ItemListNavigationButton? + if state.checking { + rightNavigationButton = ItemListNavigationButton(title: "", style: .activity, enabled: true, action: {}) + } else { + var nextEnabled = true + if state.codeText.isEmpty { + nextEnabled = false + } + rightNavigationButton = ItemListNavigationButton(title: "Next", style: .bold, enabled: nextEnabled, action: { + checkCode() + }) + } + + let controllerState = ItemListControllerState(title: "E-Mail Code", leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, animateChanges: false) + let listState = ItemListNodeState(entries: twoStepVerificationResetControllerEntries(state: state, emailPattern: emailPattern), style: .blocks, focusItemTag: TwoStepVerificationResetTag.input, emptyStateItem: nil, animateChanges: false) + + return (controllerState, (listState, arguments)) + } |> afterDisposed { + actionsDisposable.dispose() + } + + let controller = ItemListController(signal) + controller.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + + presentControllerImpl = { [weak controller] c, p in + if let controller = controller { + controller.present(c, in: .window, with: p) + } + } + dismissImpl = { [weak controller] in + controller?.dismiss() + } + + return controller +} diff --git a/TelegramUI/TwoStepVerificationUnlockController.swift b/TelegramUI/TwoStepVerificationUnlockController.swift new file mode 100644 index 0000000000..b65a5f740d --- /dev/null +++ b/TelegramUI/TwoStepVerificationUnlockController.swift @@ -0,0 +1,529 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private final class TwoStepVerificationUnlockSettingsControllerArguments { + let updatePasswordText: (String) -> Void + let openForgotPassword: () -> Void + let openSetupPassword: () -> Void + let openDisablePassword: () -> Void + let openSetupEmail: () -> Void + let openResetPendingEmail: () -> Void + + init(updatePasswordText: @escaping (String) -> Void, openForgotPassword: @escaping () -> Void, openSetupPassword: @escaping () -> Void, openDisablePassword: @escaping () -> Void, openSetupEmail: @escaping () -> Void, openResetPendingEmail: @escaping () -> Void) { + self.updatePasswordText = updatePasswordText + self.openForgotPassword = openForgotPassword + self.openSetupPassword = openSetupPassword + self.openDisablePassword = openDisablePassword + self.openSetupEmail = openSetupEmail + self.openResetPendingEmail = openResetPendingEmail + } +} + +private enum TwoStepVerificationUnlockSettingsSection: Int32 { + case password +} + +private enum TwoStepVerificationUnlockSettingsEntryTag: ItemListItemTag { + case password + + func isEqual(to other: ItemListItemTag) -> Bool { + if let other = other as? TwoStepVerificationUnlockSettingsEntryTag { + switch self { + case .password: + if case .password = other { + return true + } else { + return false + } + } + } else { + return false + } + } +} + +private enum TwoStepVerificationUnlockSettingsEntry: ItemListNodeEntry { + case passwordEntry(String) + case passwordEntryInfo(String) + + case passwordSetup + case passwordSetupInfo(String) + + case changePassword + case turnPasswordOff + case setupRecoveryEmail(Bool) + case passwordInfo(String) + + case pendingEmailInfo(String) + + var section: ItemListSectionId { + return TwoStepVerificationUnlockSettingsSection.password.rawValue + } + + var stableId: Int32 { + switch self { + case .passwordEntry: + return 0 + case .passwordEntryInfo: + return 1 + case .passwordSetup: + return 2 + case .passwordSetupInfo: + return 3 + case .changePassword: + return 4 + case .turnPasswordOff: + return 5 + case .setupRecoveryEmail: + return 6 + case .passwordInfo: + return 7 + case .pendingEmailInfo: + return 8 + } + } + + static func ==(lhs: TwoStepVerificationUnlockSettingsEntry, rhs: TwoStepVerificationUnlockSettingsEntry) -> Bool { + switch lhs { + case let .passwordEntry(text): + if case .passwordEntry(text) = rhs { + return true + } else { + return false + } + case let .passwordEntryInfo(text): + if case .passwordEntryInfo(text) = rhs { + return true + } else { + return false + } + case let .passwordSetupInfo(text): + if case .passwordSetupInfo(text) = rhs { + return true + } else { + return false + } + case let .setupRecoveryEmail(exists): + if case .setupRecoveryEmail(exists) = rhs { + return true + } else { + return false + } + case let .passwordInfo(text): + if case .passwordInfo(text) = rhs { + return true + } else { + return false + } + case let .pendingEmailInfo(text): + if case .pendingEmailInfo(text) = rhs { + return true + } else { + return false + } + case .passwordSetup, .changePassword, .turnPasswordOff: + return lhs.stableId == rhs.stableId + } + } + + static func <(lhs: TwoStepVerificationUnlockSettingsEntry, rhs: TwoStepVerificationUnlockSettingsEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(_ arguments: TwoStepVerificationUnlockSettingsControllerArguments) -> ListViewItem { + switch self { + case let .passwordEntry(text): + return ItemListSingleLineInputItem(title: NSAttributedString(string: "Password", textColor: .black), text: text, placeholder: "", type: .password, spacing: 10.0, tag: TwoStepVerificationUnlockSettingsEntryTag.password, sectionId: self.section, textUpdated: { updatedText in + arguments.updatePasswordText(updatedText) + }, action: { + }) + case let .passwordEntryInfo(text): + return ItemListTextItem(text: .markdown(text), sectionId: self.section, linkAction: { action in + switch action { + case .tap: + arguments.openForgotPassword() + } + }) + case .passwordSetup: + return ItemListActionItem(title: "Set Additional Password", kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { + arguments.openSetupPassword() + }) + case let .passwordSetupInfo(text): + return ItemListTextItem(text: .markdown(text), sectionId: self.section) + case .changePassword: + return ItemListActionItem(title: "Change Password", kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { + arguments.openSetupPassword() + }) + case .turnPasswordOff: + return ItemListActionItem(title: "Turn Password Off", kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { + arguments.openDisablePassword() + }) + case let .setupRecoveryEmail(exists): + let title: String + if exists { + title = "Change Recovery E-Mail" + } else { + title = "Set Recovery E-Mail" + } + return ItemListActionItem(title: title, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { + arguments.openSetupEmail() + }) + case let .passwordInfo(text): + return ItemListTextItem(text: .plain(text), sectionId: self.section) + case let .pendingEmailInfo(text): + return ItemListTextItem(text: .markdown(text), sectionId: self.section, linkAction: { action in + switch action { + case .tap: + arguments.openResetPendingEmail() + } + }) + } + } +} + +private struct TwoStepVerificationUnlockSettingsControllerState: Equatable { + let passwordText: String + let checking: Bool + + init(passwordText: String, checking: Bool) { + self.passwordText = passwordText + self.checking = checking + } + + static func ==(lhs: TwoStepVerificationUnlockSettingsControllerState, rhs: TwoStepVerificationUnlockSettingsControllerState) -> Bool { + if lhs.passwordText != rhs.passwordText { + return false + } + if lhs.checking != rhs.checking { + return false + } + + return true + } + + func withUpdatedPasswordText(_ passwordText: String) -> TwoStepVerificationUnlockSettingsControllerState { + return TwoStepVerificationUnlockSettingsControllerState(passwordText: passwordText, checking: self.checking) + } + + func withUpdatedChecking(_ cheking: Bool) -> TwoStepVerificationUnlockSettingsControllerState { + return TwoStepVerificationUnlockSettingsControllerState(passwordText: self.passwordText, checking: cheking) + } +} + +private func twoStepVerificationUnlockSettingsControllerEntries(state: TwoStepVerificationUnlockSettingsControllerState,data: TwoStepVerificationUnlockSettingsControllerData) -> [TwoStepVerificationUnlockSettingsEntry] { + var entries: [TwoStepVerificationUnlockSettingsEntry] = [] + + switch data { + case let .access(configuration): + if let configuration = configuration { + switch configuration { + case let .notSet(pendingEmailPattern): + if pendingEmailPattern.isEmpty { + entries.append(.passwordSetup) + entries.append(.passwordSetupInfo("You can set a password that will be required when you log in on a new device in addition to the code you cat in the SMS.")) + } else { + entries.append(.pendingEmailInfo("Please check your e-mail and click on the validation link to complete Two-Step verification setup. Be sure to check the spam folder as well.\n\n\(pendingEmailPattern)\n\n[Abort Two-Step Verification Setup]()")) + } + case let .set(hint, _, _): + entries.append(.passwordEntry(state.passwordText)) + if hint.isEmpty { + entries.append(.passwordEntryInfo("You have enabled Two-Step verification, so your account is protected with an additional password.\n\n[Forgot password?](forgot)")) + } else { + entries.append(.passwordEntryInfo("hint: \(escapedPlaintextForMarkdown(hint))\n\nYou have enabled Two-Step verification, so your account is protected with an additional password.\n\n[Forgot password?](forgot)")) + } + } + } + case let .manage(_, emailSet, pendingEmailPattern): + entries.append(.changePassword) + entries.append(.turnPasswordOff) + entries.append(.setupRecoveryEmail(emailSet)) + if pendingEmailPattern.isEmpty { + entries.append(.passwordInfo("You have enabled Two-Step verification.\nYou'll need the password you set up here to log in to your Telegram account.")) + } else { + entries.append(.passwordInfo("Your recovery e-mail \(pendingEmailPattern) is not yet active and pending confirmation.")) + } + } + + return entries +} + +enum TwoStepVerificationUnlockSettingsControllerMode { + case access + case manage(password: String, email: String, pendingEmailPattern: String) +} + +private enum TwoStepVerificationUnlockSettingsControllerData { + case access(configuration: TwoStepVerificationConfiguration?) + case manage(password: String, emailSet: Bool, pendingEmailPattern: String) +} + +func twoStepVerificationUnlockSettingsController(account: Account, mode: TwoStepVerificationUnlockSettingsControllerMode) -> ViewController { + let initialState = TwoStepVerificationUnlockSettingsControllerState(passwordText: "", checking: false) + + let statePromise = ValuePromise(initialState, ignoreRepeated: true) + let stateValue = Atomic(value: initialState) + let updateState: ((TwoStepVerificationUnlockSettingsControllerState) -> TwoStepVerificationUnlockSettingsControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var replaceControllerImpl: ((ViewController) -> Void)? + var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments) -> Void)? + + let actionsDisposable = DisposableSet() + + let checkDisposable = MetaDisposable() + actionsDisposable.add(checkDisposable) + + let setupDisposable = MetaDisposable() + actionsDisposable.add(setupDisposable) + + let setupResultDisposable = MetaDisposable() + actionsDisposable.add(setupResultDisposable) + + let dataPromise = Promise() + + switch mode { + case .access: + dataPromise.set(.single(TwoStepVerificationUnlockSettingsControllerData.access(configuration: nil)) |> then(twoStepVerificationConfiguration(account: account) |> map { TwoStepVerificationUnlockSettingsControllerData.access(configuration: $0) })) + case let .manage(password, email, pendingEmailPattern): + dataPromise.set(.single(.manage(password: password, emailSet: !email.isEmpty, pendingEmailPattern: pendingEmailPattern))) + } + + let arguments = TwoStepVerificationUnlockSettingsControllerArguments(updatePasswordText: { updatedText in + updateState { + $0.withUpdatedPasswordText(updatedText) + } + }, openForgotPassword: { + setupDisposable.set((dataPromise.get() |> take(1) |> deliverOnMainQueue).start(next: { data in + switch data { + case let .access(configuration): + if let configuration = configuration { + switch configuration { + case let .set(_, hasRecoveryEmail, _): + if hasRecoveryEmail { + updateState { + $0.withUpdatedChecking(true) + } + setupResultDisposable.set((requestTwoStepVerificationPasswordRecoveryCode(account: account) |> deliverOnMainQueue).start(next: { emailPattern in + updateState { + $0.withUpdatedChecking(false) + } + let result = Promise() + let controller = twoStepVerificationResetController(account: account, emailPattern: emailPattern, result: result) + presentControllerImpl?(controller, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + setupDisposable.set((result.get() |> take(1) |> deliverOnMainQueue).start(next: { [weak controller] _ in + dataPromise.set(.single(TwoStepVerificationUnlockSettingsControllerData.access(configuration: TwoStepVerificationConfiguration.notSet(pendingEmailPattern: "")))) + controller?.dismiss() + })) + }, error: { _ in + updateState { + $0.withUpdatedChecking(false) + } + presentControllerImpl?(standardTextAlertController(title: nil, text: "An error occured. Please try again later.", actions: [TextAlertAction(type: .defaultAction, title: "OK", action: {})]), ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + })) + } else { + presentControllerImpl?(standardTextAlertController(title: nil, text: "Since you haven't provided a recovery e-mail when setting up your password, your remaining options are either to remember your password or to reset your account.", actions: [TextAlertAction(type: .defaultAction, title: "OK", action: {})]), ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + } + case .notSet: + break + } + } + case .manage: + break + } + })) + }, openSetupPassword: { + setupDisposable.set((dataPromise.get() |> take(1) |> deliverOnMainQueue).start(next: { data in + switch data { + case let .access(configuration): + if let configuration = configuration { + switch configuration { + case .notSet: + let result = Promise() + let controller = twoStepVerificationPasswordEntryController(account: account, mode: .setup, result: result) + presentControllerImpl?(controller, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + setupResultDisposable.set((result.get() |> take(1) |> deliverOnMainQueue).start(next: { [weak controller] updatedPassword in + if let updatedPassword = updatedPassword { + if let pendingEmailPattern = updatedPassword.pendingEmailPattern { + dataPromise.set(.single(TwoStepVerificationUnlockSettingsControllerData.access(configuration: TwoStepVerificationConfiguration.notSet(pendingEmailPattern: pendingEmailPattern)))) + } else { + dataPromise.set(.single(TwoStepVerificationUnlockSettingsControllerData.manage(password: updatedPassword.password, emailSet: false, pendingEmailPattern: ""))) + } + controller?.dismiss() + } + })) + case .set: + break + } + } + case let .manage(password, emailSet, pendingEmailPattern): + let result = Promise() + let controller = twoStepVerificationPasswordEntryController(account: account, mode: .change(current: password), result: result) + presentControllerImpl?(controller, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + setupResultDisposable.set((result.get() |> take(1) |> deliverOnMainQueue).start(next: { [weak controller] updatedPassword in + if let updatedPassword = updatedPassword { + dataPromise.set(.single(TwoStepVerificationUnlockSettingsControllerData.manage(password: updatedPassword.password, emailSet: emailSet, pendingEmailPattern: pendingEmailPattern))) + controller?.dismiss() + } + })) + } + })) + }, openDisablePassword: { + presentControllerImpl?(standardTextAlertController(title: nil, text: "Are you sure you want to disable your password?", actions: [TextAlertAction(type: .defaultAction, title: "Cancel", action: {}), TextAlertAction(type: .genericAction, title: "OK", action: { + var disablePassword = false + updateState { state in + if state.checking { + return state + } else { + disablePassword = true + return state.withUpdatedChecking(true) + } + } + if disablePassword { + setupDisposable.set((dataPromise.get() + |> take(1) + |> mapError { _ -> UpdateTwoStepVerificationPasswordError in return .generic } + |> mapToSignal { data -> Signal in + switch data { + case .access: + return .complete() + case let .manage(password, _, _): + return updateTwoStepVerificationPassword(account: account, currentPassword: password, updatedPassword: .none) + |> mapToSignal { _ -> Signal in + return .complete() + } + } + } + |> deliverOnMainQueue).start(error: { _ in + updateState { + $0.withUpdatedChecking(false) + } + }, completed: { + updateState { + $0.withUpdatedChecking(false) + } + dataPromise.set(.single(TwoStepVerificationUnlockSettingsControllerData.access(configuration: .notSet(pendingEmailPattern: "")))) + })) + } + })]), ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + }, openSetupEmail: { + setupDisposable.set((dataPromise.get() |> take(1) |> deliverOnMainQueue).start(next: { data in + switch data { + case .access: + break + case let .manage(password, _, _): + let result = Promise() + let controller = twoStepVerificationPasswordEntryController(account: account, mode: .setupEmail(password: password), result: result) + presentControllerImpl?(controller, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + setupResultDisposable.set((result.get() |> take(1) |> deliverOnMainQueue).start(next: { [weak controller] updatedPassword in + if let updatedPassword = updatedPassword { + dataPromise.set(.single(TwoStepVerificationUnlockSettingsControllerData.manage(password: updatedPassword.password, emailSet: true, pendingEmailPattern: updatedPassword.pendingEmailPattern ?? ""))) + controller?.dismiss() + } + })) + } + })) + }, openResetPendingEmail: { + updateState { state in + return state.withUpdatedChecking(true) + } + setupDisposable.set((updateTwoStepVerificationPassword(account: account, currentPassword: nil, updatedPassword: .none) |> deliverOnMainQueue).start(next: { _ in + updateState { state in + return state.withUpdatedChecking(false) + } + dataPromise.set(.single(TwoStepVerificationUnlockSettingsControllerData.access(configuration: .notSet(pendingEmailPattern: "")))) + }, error: { _ in + updateState { state in + return state.withUpdatedChecking(false) + } + })) + }) + + let signal = combineLatest(statePromise.get(), dataPromise.get() |> deliverOnMainQueue) |> deliverOnMainQueue + |> map { state, data -> (ItemListControllerState, (ItemListNodeState, TwoStepVerificationUnlockSettingsEntry.ItemGenerationArguments)) in + + var rightNavigationButton: ItemListNavigationButton? + var emptyStateItem: ItemListControllerEmptyStateItem? + let title: String + switch data { + case let .access(configuration): + title = "Password" + if let configuration = configuration { + if state.checking { + rightNavigationButton = ItemListNavigationButton(title: "", style: .activity, enabled: true, action: {}) + } else { + switch configuration { + case .notSet: + break + case .set: + rightNavigationButton = ItemListNavigationButton(title: "Next", style: .bold, enabled: true, action: { + var wasChecking = false + var password: String? + updateState { state in + wasChecking = state.checking + password = state.passwordText + return state.withUpdatedChecking(true) + } + + if let password = password, !wasChecking { + checkDisposable.set((requestTwoStepVerifiationSettings(account: account, password: password) |> deliverOnMainQueue).start(next: { settings in + updateState { + $0.withUpdatedChecking(false) + } + + replaceControllerImpl?(twoStepVerificationUnlockSettingsController(account: account, mode: .manage(password: password, email: settings.email, pendingEmailPattern: ""))) + }, error: { error in + updateState { + $0.withUpdatedChecking(false) + } + + let text: String + switch error { + case .limitExceeded: + text = "You have entered invalid password too many times. Please try again later." + case .invalidPassword: + text = "Invalid password. Please try again." + case .generic: + text = "An error occured. Please try again later." + } + + presentControllerImpl?(standardTextAlertController(title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: "OK", action: {})]), ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + })) + } + }) + } + } + } else { + emptyStateItem = ItemListLoadingIndicatorEmptyStateItem() + } + case .manage: + title = "Two-Step Verification" + if state.checking { + rightNavigationButton = ItemListNavigationButton(title: "", style: .activity, enabled: true, action: {}) + } + } + + let controllerState = ItemListControllerState(title: title, leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: false) + let listState = ItemListNodeState(entries: twoStepVerificationUnlockSettingsControllerEntries(state: state, data: data), style: .blocks, focusItemTag: TwoStepVerificationUnlockSettingsEntryTag.password, emptyStateItem: emptyStateItem, animateChanges: false) + + return (controllerState, (listState, arguments)) + } |> afterDisposed { + actionsDisposable.dispose() + } + + let controller = ItemListController(signal) + controller.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + replaceControllerImpl = { [weak controller] c in + (controller?.navigationController as? NavigationController)?.replaceTopController(c, animated: true) + } + presentControllerImpl = { [weak controller] c, p in + if let controller = controller { + controller.present(c, in: .window, with: p) + } + } + + return controller +} diff --git a/TelegramUI/UserInfoController.swift b/TelegramUI/UserInfoController.swift index 685253b334..852ea0e747 100644 --- a/TelegramUI/UserInfoController.swift +++ b/TelegramUI/UserInfoController.swift @@ -7,15 +7,17 @@ import TelegramCore private final class UserInfoControllerArguments { let account: Account let updateEditingName: (ItemListAvatarAndNameInfoItemName) -> Void + let openChat: () -> Void let changeNotificationMuteSettings: () -> Void let openSharedMedia: () -> Void let openGroupsInCommon: () -> Void let updatePeerBlocked: (Bool) -> Void let deleteContact: () -> Void - init(account: Account, updateEditingName: @escaping (ItemListAvatarAndNameInfoItemName) -> Void, changeNotificationMuteSettings: @escaping () -> Void, openSharedMedia: @escaping () -> Void, openGroupsInCommon: @escaping () -> Void, updatePeerBlocked: @escaping (Bool) -> Void, deleteContact: @escaping () -> Void) { + init(account: Account, updateEditingName: @escaping (ItemListAvatarAndNameInfoItemName) -> Void, openChat: @escaping () -> Void, changeNotificationMuteSettings: @escaping () -> Void, openSharedMedia: @escaping () -> Void, openGroupsInCommon: @escaping () -> Void, updatePeerBlocked: @escaping (Bool) -> Void, deleteContact: @escaping () -> Void) { self.account = account self.updateEditingName = updateEditingName + self.openChat = openChat self.changeNotificationMuteSettings = changeNotificationMuteSettings self.openSharedMedia = openSharedMedia self.openGroupsInCommon = openGroupsInCommon @@ -240,7 +242,7 @@ private enum UserInfoEntry: ItemListNodeEntry { return ItemListTextWithLabelItem(label: "username", text: "@\(value)", multiline: false, sectionId: self.section) case .sendMessage: return ItemListActionItem(title: "Send Message", kind: .generic, alignment: .natural, sectionId: self.section, style: .plain, action: { - + arguments.openChat() }) case .shareContact: return ItemListActionItem(title: "Share Contact", kind: .generic, alignment: .natural, sectionId: self.section, style: .plain, action: { @@ -350,7 +352,7 @@ private struct UserInfoState: Equatable { } } -private func userInfoEntries(account: Account, view: PeerView, state: UserInfoState) -> [UserInfoEntry] { +private func userInfoEntries(account: Account, view: PeerView, state: UserInfoState, peerChatState: Coding?) -> [UserInfoEntry] { var entries: [UserInfoEntry] = [] guard let peer = view.peers[view.peerId], let user = peerViewMainPeer(view) as? TelegramUser else { @@ -398,8 +400,8 @@ private func userInfoEntries(account: Account, view: PeerView, state: UserInfoSt entries.append(UserInfoEntry.groupsInCommon(groupsInCommon)) } - if let _ = peer as? TelegramSecretChat { - entries.append(UserInfoEntry.secretEncryptionKey(SecretChatKeyFingerprint(k0: 0, k1: 0, k2: 0, k3: 0))) + if peer is TelegramSecretChat, let peerChatState = peerChatState as? SecretChatKeyState, let keyFingerprint = peerChatState.keyFingerprint { + entries.append(UserInfoEntry.secretEncryptionKey(keyFingerprint)) } if isEditing { @@ -429,6 +431,7 @@ public func userInfoController(account: Account, peerId: PeerId) -> ViewControll var pushControllerImpl: ((ViewController) -> Void)? var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments) -> Void)? + var openChatImpl: (() -> Void)? let actionsDisposable = DisposableSet() @@ -447,12 +450,14 @@ public func userInfoController(account: Account, peerId: PeerId) -> ViewControll let arguments = UserInfoControllerArguments(account: account, updateEditingName: { editingName in updateState { state in - if let editingState = state.editingState { + if let _ = state.editingState { return state.withUpdatedEditingState(UserInfoEditingState(editingName: editingName)) } else { return state } } + }, openChat: { + openChatImpl?() }, changeNotificationMuteSettings: { let controller = ActionSheetController() let dismissAction: () -> Void = { [weak controller] in @@ -507,8 +512,8 @@ public func userInfoController(account: Account, peerId: PeerId) -> ViewControll }) - let signal = combineLatest(statePromise.get(), account.viewTracker.peerView(peerId)) - |> map { state, view -> (ItemListControllerState, (ItemListNodeState, UserInfoEntry.ItemGenerationArguments)) in + let signal = combineLatest(statePromise.get(), account.viewTracker.peerView(peerId), account.postbox.combinedView(keys: [.peerChatState(peerId: peerId)])) + |> map { state, view, chatState -> (ItemListControllerState, (ItemListNodeState, UserInfoEntry.ItemGenerationArguments)) in let peer = peerViewMainPeer(view) var leftNavigationButton: ItemListNavigationButton? let rightNavigationButton: ItemListNavigationButton @@ -568,7 +573,7 @@ public func userInfoController(account: Account, peerId: PeerId) -> ViewControll } let controllerState = ItemListControllerState(title: "Info", leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton) - let listState = ItemListNodeState(entries: userInfoEntries(account: account, view: view, state: state), style: .plain) + let listState = ItemListNodeState(entries: userInfoEntries(account: account, view: view, state: state, peerChatState: (chatState.views[.peerChatState(peerId: peerId)] as? PeerChatStateView)?.chatState), style: .plain) return (controllerState, (listState, arguments)) } |> afterDisposed { @@ -583,5 +588,10 @@ public func userInfoController(account: Account, peerId: PeerId) -> ViewControll presentControllerImpl = { [weak controller] value, presentationArguments in controller?.present(value, in: .window, with: presentationArguments) } + openChatImpl = { [weak controller] in + if let navigationController = (controller?.navigationController as? NavigationController) { + navigateToChatController(navigationController: navigationController, account: account, peerId: peerId) + } + } return controller } diff --git a/TelegramUI/UserInfoEntries.swift b/TelegramUI/UserInfoEntries.swift deleted file mode 100644 index a2d18d4b73..0000000000 --- a/TelegramUI/UserInfoEntries.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation -import Postbox -import TelegramCore -import SwiftSignalKit -import Display - - - -/*func userInfoEntries(view: PeerView, state: PeerInfoState?) -> PeerInfoEntries { - - - var leftNavigationButton: PeerInfoNavigationButton? - var rightNavigationButton: PeerInfoNavigationButton? - if editable { - if let state = state as? UserInfoState, let _ = state.editingState { - leftNavigationButton = PeerInfoNavigationButton(title: "Cancel", action: { state in - if state == nil { - return UserInfoState(editingState: nil) - } else if let state = state as? UserInfoState { - return state.updateEditingState(nil) - } else { - return state - } - }) - rightNavigationButton = PeerInfoNavigationButton(title: "Done", action: { state in - if state == nil { - return UserInfoState(editingState: nil) - } else if let state = state as? UserInfoState { - return state.updateEditingState(nil) - } else { - return state - } - }) - } else { - let infoEditingName: ItemListAvatarAndNameInfoItemName - if let peer = peerViewMainPeer(view) { - infoEditingName = ItemListAvatarAndNameInfoItemName(peer.indexName) - } else { - infoEditingName = .personName(firstName: "", lastName: "") - } - rightNavigationButton = PeerInfoNavigationButton(title: "Edit", action: { state in - if state == nil { - return UserInfoState(editingState: UserInfoEditingState(editingName: infoEditingName)) - } else if let state = state as? UserInfoState { - return state.updateEditingState(UserInfoEditingState(editingName: infoEditingName)) - } else { - return state - } - }) - } - } - - return PeerInfoEntries(entries: entries, leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton) -}*/ diff --git a/TelegramUI/UsernameSetupController.swift b/TelegramUI/UsernameSetupController.swift index 3acaf1d55e..8f2688872b 100644 --- a/TelegramUI/UsernameSetupController.swift +++ b/TelegramUI/UsernameSetupController.swift @@ -78,7 +78,7 @@ private enum UsernameSetupEntry: ItemListNodeEntry { }) case let .publicLinkInfo(text): - return ItemListTextItem(text: text, sectionId: self.section) + return ItemListTextItem(text: .plain(text), sectionId: self.section) case let .publicLinkStatus(addressName, status): var displayActivity = false let text: NSAttributedString diff --git a/TelegramUI/WebpagePreviewAccessoryPanelNode.swift b/TelegramUI/WebpagePreviewAccessoryPanelNode.swift index 58da8c673d..611dcac446 100644 --- a/TelegramUI/WebpagePreviewAccessoryPanelNode.swift +++ b/TelegramUI/WebpagePreviewAccessoryPanelNode.swift @@ -22,7 +22,7 @@ private let closeButtonImage = generateImage(CGSize(width: 12.0, height: 12.0), final class WebpagePreviewAccessoryPanelNode: AccessoryPanelNode { private let webpageDisposable = MetaDisposable() - private (set) var webpage: TelegramMediaWebpage + private(set) var webpage: TelegramMediaWebpage let closeButton: ASButtonNode let lineNode: ASImageNode @@ -68,6 +68,13 @@ final class WebpagePreviewAccessoryPanelNode: AccessoryPanelNode { self.webpageDisposable.dispose() } + func replaceWebpage(_ webpage: TelegramMediaWebpage) { + if !self.webpage.isEqual(webpage) { + self.webpage = webpage + self.updateWebpage() + } + } + private func updateWebpage() { var authorName = "" var text = ""