diff --git a/Images.xcassets/Chat List/LockLockedBottom.imageset/ChatListLock_LockedBottom@2x.png b/Images.xcassets/Chat List/LockLockedBottom.imageset/ChatListLock_LockedBottom@2x.png new file mode 100644 index 0000000000..6c6bbe3e6b Binary files /dev/null and b/Images.xcassets/Chat List/LockLockedBottom.imageset/ChatListLock_LockedBottom@2x.png differ diff --git a/Images.xcassets/Chat List/LockLockedBottom.imageset/Contents.json b/Images.xcassets/Chat List/LockLockedBottom.imageset/Contents.json new file mode 100644 index 0000000000..d55ce1215a --- /dev/null +++ b/Images.xcassets/Chat List/LockLockedBottom.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ChatListLock_LockedBottom@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Chat List/LockLockedTop.imageset/ChatListLock_LockedTop@2x.png b/Images.xcassets/Chat List/LockLockedTop.imageset/ChatListLock_LockedTop@2x.png new file mode 100644 index 0000000000..9bdcc22b16 Binary files /dev/null and b/Images.xcassets/Chat List/LockLockedTop.imageset/ChatListLock_LockedTop@2x.png differ diff --git a/Images.xcassets/Chat List/LockLockedTop.imageset/Contents.json b/Images.xcassets/Chat List/LockLockedTop.imageset/Contents.json new file mode 100644 index 0000000000..eb6ec5fb8d --- /dev/null +++ b/Images.xcassets/Chat List/LockLockedTop.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ChatListLock_LockedTop@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Chat List/LockUnlockedBottom.imageset/ChatListLock_UnlockedBottom@2x.png b/Images.xcassets/Chat List/LockUnlockedBottom.imageset/ChatListLock_UnlockedBottom@2x.png new file mode 100644 index 0000000000..16c40761e0 Binary files /dev/null and b/Images.xcassets/Chat List/LockUnlockedBottom.imageset/ChatListLock_UnlockedBottom@2x.png differ diff --git a/Images.xcassets/Chat List/LockUnlockedBottom.imageset/Contents.json b/Images.xcassets/Chat List/LockUnlockedBottom.imageset/Contents.json new file mode 100644 index 0000000000..c540009597 --- /dev/null +++ b/Images.xcassets/Chat List/LockUnlockedBottom.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ChatListLock_UnlockedBottom@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Chat List/LockUnlockedTop.imageset/ChatListLock_UnlockedTop@2x.png b/Images.xcassets/Chat List/LockUnlockedTop.imageset/ChatListLock_UnlockedTop@2x.png new file mode 100644 index 0000000000..e7b1c74033 Binary files /dev/null and b/Images.xcassets/Chat List/LockUnlockedTop.imageset/ChatListLock_UnlockedTop@2x.png differ diff --git a/Images.xcassets/Chat List/LockUnlockedTop.imageset/Contents.json b/Images.xcassets/Chat List/LockUnlockedTop.imageset/Contents.json new file mode 100644 index 0000000000..4ff779de7e --- /dev/null +++ b/Images.xcassets/Chat List/LockUnlockedTop.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ChatListLock_UnlockedTop@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Chat/Input/Acessory Panels/MessageSelectionAction.imageset/ActionsWhiteIcon@2x.png b/Images.xcassets/Chat/Input/Acessory Panels/MessageSelectionAction.imageset/ActionsWhiteIcon@2x.png new file mode 100644 index 0000000000..2ad26719c3 Binary files /dev/null and b/Images.xcassets/Chat/Input/Acessory Panels/MessageSelectionAction.imageset/ActionsWhiteIcon@2x.png differ diff --git a/Images.xcassets/Chat/Input/Acessory Panels/MessageSelectionAction.imageset/ActionsWhiteIcon@3x.png b/Images.xcassets/Chat/Input/Acessory Panels/MessageSelectionAction.imageset/ActionsWhiteIcon@3x.png new file mode 100644 index 0000000000..98dc6d8d62 Binary files /dev/null and b/Images.xcassets/Chat/Input/Acessory Panels/MessageSelectionAction.imageset/ActionsWhiteIcon@3x.png differ diff --git a/Images.xcassets/Chat/Input/Acessory Panels/MessageSelectionAction.imageset/Contents.json b/Images.xcassets/Chat/Input/Acessory Panels/MessageSelectionAction.imageset/Contents.json new file mode 100644 index 0000000000..f799c88673 --- /dev/null +++ b/Images.xcassets/Chat/Input/Acessory Panels/MessageSelectionAction.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ActionsWhiteIcon@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "ActionsWhiteIcon@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Chat/Message/ShareIcon.imageset/Contents.json b/Images.xcassets/Chat/Message/ShareIcon.imageset/Contents.json new file mode 100644 index 0000000000..52f3acda4b --- /dev/null +++ b/Images.xcassets/Chat/Message/ShareIcon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ConversationChannelInlineShareIcon@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "ConversationChannelInlineShareIcon@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Chat/Message/ShareIcon.imageset/ConversationChannelInlineShareIcon@2x.png b/Images.xcassets/Chat/Message/ShareIcon.imageset/ConversationChannelInlineShareIcon@2x.png new file mode 100644 index 0000000000..1551864522 Binary files /dev/null and b/Images.xcassets/Chat/Message/ShareIcon.imageset/ConversationChannelInlineShareIcon@2x.png differ diff --git a/Images.xcassets/Chat/Message/ShareIcon.imageset/ConversationChannelInlineShareIcon@3x.png b/Images.xcassets/Chat/Message/ShareIcon.imageset/ConversationChannelInlineShareIcon@3x.png new file mode 100644 index 0000000000..600f3a10be Binary files /dev/null and b/Images.xcassets/Chat/Message/ShareIcon.imageset/ConversationChannelInlineShareIcon@3x.png differ diff --git a/TelegramUI.xcodeproj/project.pbxproj b/TelegramUI.xcodeproj/project.pbxproj index 03e542894f..3c4b485696 100644 --- a/TelegramUI.xcodeproj/project.pbxproj +++ b/TelegramUI.xcodeproj/project.pbxproj @@ -9,6 +9,13 @@ /* Begin PBXBuildFile section */ D00219041DDCC86400BE708A /* PerformanceSpinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00219031DDCC86400BE708A /* PerformanceSpinner.swift */; }; D00219061DDD1C9E00BE708A /* ImageContainingNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00219051DDD1C9E00BE708A /* ImageContainingNode.swift */; }; + D002A0D11E9B99F500A81812 /* SoftwareVideoSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D002A0D01E9B99F500A81812 /* SoftwareVideoSource.swift */; }; + D002A0D31E9BBE6700A81812 /* MultiplexedSoftwareVideoSourceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D002A0D21E9BBE6700A81812 /* MultiplexedSoftwareVideoSourceManager.swift */; }; + D002A0D51E9BD48400A81812 /* SampleBufferPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = D002A0D41E9BD48400A81812 /* SampleBufferPool.swift */; }; + D002A0D71E9BD92100A81812 /* MultiplexedVideoNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D002A0D61E9BD92100A81812 /* MultiplexedVideoNode.swift */; }; + D002A0D91E9BEC8100A81812 /* SoftwareVideoLayerFrameManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D002A0D81E9BEC8100A81812 /* SoftwareVideoLayerFrameManager.swift */; }; + D002A0DB1E9C190700A81812 /* SoftwareVideoThumbnailLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D002A0DA1E9C190700A81812 /* SoftwareVideoThumbnailLayer.swift */; }; + D002A0DD1E9CD52A00A81812 /* ChatMediaInputRecentGifsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D002A0DC1E9CD52A00A81812 /* ChatMediaInputRecentGifsItem.swift */; }; D003702E1DA43052004308D3 /* ItemListAvatarAndNameItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D003702D1DA43052004308D3 /* ItemListAvatarAndNameItem.swift */; }; D00370301DA43077004308D3 /* ItemListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D003702F1DA43077004308D3 /* ItemListItem.swift */; }; D00370321DA46C06004308D3 /* ItemListTextWithLabelItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00370311DA46C06004308D3 /* ItemListTextWithLabelItem.swift */; }; @@ -25,6 +32,10 @@ D00C7CF81E37BF680080C3D5 /* SecretChatKeyVisualization.m in Sources */ = {isa = PBXBuildFile; fileRef = D00C7CF61E37BF680080C3D5 /* SecretChatKeyVisualization.m */; }; D00D34371E6E14E30057B307 /* ChatMessageThrottledProcessingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00D34361E6E14E30057B307 /* ChatMessageThrottledProcessingManager.swift */; }; D00DBBDD1E65650800DB5485 /* ChatReportPeerTitlePanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00DBBDC1E65650800DB5485 /* ChatReportPeerTitlePanelNode.swift */; }; + D00DE6981E8E8E33003F0D76 /* ShareController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00DE6971E8E8E33003F0D76 /* ShareController.swift */; }; + D00DE69A1E8E8E43003F0D76 /* ShareControllerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00DE6991E8E8E43003F0D76 /* ShareControllerNode.swift */; }; + D00DE69C1E8E8E97003F0D76 /* ShareControllerPeerGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00DE69B1E8E8E97003F0D76 /* ShareControllerPeerGridItem.swift */; }; + D00DE6AD1E8EB2D4003F0D76 /* ShareActionButtonNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00DE6AC1E8EB2D4003F0D76 /* ShareActionButtonNode.swift */; }; D00E15261DDBD4E700ACF65C /* LegacyCamera.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00E15251DDBD4E700ACF65C /* LegacyCamera.swift */; }; D0105D5A1D80B957008755D8 /* ChatChannelSubscriberInputPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0105D591D80B957008755D8 /* ChatChannelSubscriberInputPanelNode.swift */; }; D0127A0D1E6424AC003BFF2E /* ChatPinnedMessageTitlePanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0127A0C1E6424AC003BFF2E /* ChatPinnedMessageTitlePanelNode.swift */; }; @@ -46,7 +57,7 @@ D01B279B1E39386C0022A4C0 /* SettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01B279A1E39386C0022A4C0 /* SettingsController.swift */; }; 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 */; }; + D01B27A41E394FC90022A4C0 /* PresentationPasscodeSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01B27A31E394FC90022A4C0 /* PresentationPasscodeSettings.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 */; }; @@ -76,6 +87,11 @@ D021E0D01DB413BC00C6B04F /* ChatInputNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D021E0CF1DB413BC00C6B04F /* ChatInputNode.swift */; }; D021E0D21DB4147500C6B04F /* ChatInterfaceInputNodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D021E0D11DB4147500C6B04F /* ChatInterfaceInputNodes.swift */; }; D021E0E51DB55D0A00C6B04F /* ChatMediaInputStickerPackItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D021E0E41DB55D0A00C6B04F /* ChatMediaInputStickerPackItem.swift */; }; + D0223A901EA53E6000211D94 /* AutomaticMediaDownloadSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0223A8F1EA53E6000211D94 /* AutomaticMediaDownloadSettings.swift */; }; + D0223A921EA5420C00211D94 /* GeneratedMediaStoreSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0223A911EA5420C00211D94 /* GeneratedMediaStoreSettings.swift */; }; + D0223A941EA5442C00211D94 /* VoiceCallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0223A931EA5442C00211D94 /* VoiceCallSettings.swift */; }; + D0223A961EA54D0D00211D94 /* VoiceCallDataSavingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0223A951EA54D0D00211D94 /* VoiceCallDataSavingController.swift */; }; + D0223A9E1EA5732300211D94 /* NetworkUsageStatsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0223A9D1EA5732300211D94 /* NetworkUsageStatsController.swift */; }; D02298371E0C34E900707F91 /* ChatMessageBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02298361E0C34E900707F91 /* ChatMessageBackground.swift */; }; D02383701DDF0462004018B6 /* UrlHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = D023836F1DDF0462004018B6 /* UrlHandling.swift */; }; D02383731DDF0D8A004018B6 /* ChatInfoTitlePanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02383721DDF0D8A004018B6 /* ChatInfoTitlePanelNode.swift */; }; @@ -103,6 +119,11 @@ D03ADB4F1D70546B005A521C /* AccessoryPanelNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03ADB4E1D70546B005A521C /* AccessoryPanelNode.swift */; }; D03E5E091E55C49C0029569A /* DebugAccountsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03E5E081E55C49C0029569A /* DebugAccountsController.swift */; }; D03E5E0F1E55F8B90029569A /* ChannelVisibilityController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03E5E0E1E55F8B90029569A /* ChannelVisibilityController.swift */; }; + D042C6811E8D9A6700C863B0 /* GalleryFooterNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D042C6801E8D9A6700C863B0 /* GalleryFooterNode.swift */; }; + D042C6861E8DA69D00C863B0 /* GalleryFooterContentNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D042C6851E8DA69D00C863B0 /* GalleryFooterContentNode.swift */; }; + D042C6881E8DA8C800C863B0 /* GalleryControllerPresentationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D042C6871E8DA8C800C863B0 /* GalleryControllerPresentationState.swift */; }; + D042C68A1E8DAAB000C863B0 /* ChatItemGalleryFooterContentNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D042C6891E8DAAB000C863B0 /* ChatItemGalleryFooterContentNode.swift */; }; + D042C68C1E8DAB7100C863B0 /* ChatItemGalleryItemNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D042C68B1E8DAB7100C863B0 /* ChatItemGalleryItemNode.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 */; }; @@ -195,6 +216,12 @@ D0561DE81E574C3200E6B9E9 /* ChannelAdminsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0561DE71E574C3200E6B9E9 /* ChannelAdminsController.swift */; }; D0568AAD1DF198130022E7DA /* AudioWaveformNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0568AAC1DF198130022E7DA /* AudioWaveformNode.swift */; }; D0568AAF1DF1B3920022E7DA /* HapticFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0568AAE1DF1B3920022E7DA /* HapticFeedback.swift */; }; + D0575AEB1E9FD579006F2541 /* ChatListTitleLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0575AEA1E9FD579006F2541 /* ChatListTitleLockView.swift */; }; + D0575AED1E9FF1AD006F2541 /* ChatMediaInputTrendingPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0575AEC1E9FF1AD006F2541 /* ChatMediaInputTrendingPane.swift */; }; + D0575AEF1E9FF881006F2541 /* ChatMediaInputTrendingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0575AEE1E9FF881006F2541 /* ChatMediaInputTrendingItem.swift */; }; + D0575AF71EA0ED4F006F2541 /* ChatMessageInstantVideoItemNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0575AF61EA0ED4F006F2541 /* ChatMessageInstantVideoItemNode.swift */; }; + D0575AFA1EA0FDA7006F2541 /* AvatarGalleryController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0575AF91EA0FDA7006F2541 /* AvatarGalleryController.swift */; }; + D0575AFC1EA104A6006F2541 /* PeerAvatarImageGalleryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0575AFB1EA104A6006F2541 /* PeerAvatarImageGalleryItem.swift */; }; D05811941DD5F9380057C769 /* TelegramApplicationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05811931DD5F9380057C769 /* TelegramApplicationContext.swift */; }; D058E0CF1E8AD57300A442DE /* VideoPlayerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D058E0CE1E8AD57300A442DE /* VideoPlayerProxy.swift */; }; D05A32DC1E6EFCC2002760B4 /* NumericFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05A32DB1E6EFCC2002760B4 /* NumericFormat.swift */; }; @@ -225,6 +252,7 @@ D075518F1DDA4F9E0073E051 /* SSignalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D075518E1DDA4F9E0073E051 /* SSignalKit.framework */; }; D07551911DDA4FC70073E051 /* libc++.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D07551901DDA4FC70073E051 /* libc++.tbd */; }; D07551931DDA540F0073E051 /* TelegramInitializeLegacyComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07551921DDA540F0073E051 /* TelegramInitializeLegacyComponents.swift */; }; + D0760B241E9D015D00F1F3C4 /* PasscodeOptionsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0760B231E9D015D00F1F3C4 /* PasscodeOptionsController.swift */; }; D07827BD1E004A3400071108 /* ChatListSearchItemHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07827BC1E004A3400071108 /* ChatListSearchItemHeader.swift */; }; D07827C71E01CD5900071108 /* VerticalListContextResultsChatInputPanelButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07827C61E01CD5900071108 /* VerticalListContextResultsChatInputPanelButtonItem.swift */; }; D07A7DA31D957671005BCD27 /* ListMessageSnippetItemNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07A7DA21D957671005BCD27 /* ListMessageSnippetItemNode.swift */; }; @@ -288,8 +316,16 @@ 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 */; }; + D0BE383C1E7C3E51000079AF /* StickerPreviewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BE383B1E7C3E51000079AF /* StickerPreviewController.swift */; }; + D0BE931B1E92DFBA00DCC1E6 /* StickerPreviewControllerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BE931A1E92DFBA00DCC1E6 /* StickerPreviewControllerNode.swift */; }; D0C48F441E81D5110075317D /* ChatEmptyItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C48F431E81D5110075317D /* ChatEmptyItem.swift */; }; + D0C50E381E93CB1500F62E39 /* NotificationContainerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C50E371E93CB1500F62E39 /* NotificationContainerController.swift */; }; + D0C50E3A1E93CB4300F62E39 /* NotificationContainerControllerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C50E391E93CB4300F62E39 /* NotificationContainerControllerNode.swift */; }; + D0C50E3C1E93CC2600F62E39 /* NotificationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C50E3B1E93CC2600F62E39 /* NotificationItem.swift */; }; + D0C50E3E1E93D09200F62E39 /* NotificationItemContainerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C50E3D1E93D09200F62E39 /* NotificationItemContainerNode.swift */; }; + D0C50E401E93D3B000F62E39 /* ChatMessageNotificationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C50E3F1E93D3B000F62E39 /* ChatMessageNotificationItem.swift */; }; + D0C50E441E93FCD200F62E39 /* notification.caf in Resources */ = {isa = PBXBuildFile; fileRef = D0C50E431E93FCD200F62E39 /* notification.caf */; }; + D0C50E471E9459E100F62E39 /* libopus.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D03B251DECB26D00220C46 /* libopus.a */; }; 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 */; }; @@ -322,7 +358,6 @@ D0D03B201DECB0FE00220C46 /* stream.c in Sources */ = {isa = PBXBuildFile; fileRef = D0D03B071DECB0FE00220C46 /* stream.c */; }; D0D03B231DECB1AD00220C46 /* TGDataItem.h in Headers */ = {isa = PBXBuildFile; fileRef = D0D03B211DECB1AD00220C46 /* TGDataItem.h */; }; D0D03B241DECB1AD00220C46 /* TGDataItem.m in Sources */ = {isa = PBXBuildFile; fileRef = D0D03B221DECB1AD00220C46 /* TGDataItem.m */; }; - D0D03B261DECB26D00220C46 /* libopus.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D03B251DECB26D00220C46 /* libopus.a */; }; D0D03B2C1DED9B8900220C46 /* AudioWaveform.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D03B2B1DED9B8900220C46 /* AudioWaveform.swift */; }; D0D268671D78793B00C422DA /* ChatInterfaceStateNavigationButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D268661D78793B00C422DA /* ChatInterfaceStateNavigationButtons.swift */; }; D0D268691D78865300C422DA /* ChatAvatarNavigationNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D268681D78865300C422DA /* ChatAvatarNavigationNode.swift */; }; @@ -372,6 +407,10 @@ 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 */; }; + D0F02CCC1E96EF350065DEE2 /* ChatMediaInputStickerPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F02CCB1E96EF350065DEE2 /* ChatMediaInputStickerPane.swift */; }; + D0F02CCE1E96FACE0065DEE2 /* ChatMediaInputGifPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F02CCD1E96FACE0065DEE2 /* ChatMediaInputGifPane.swift */; }; + D0F02CD91E97ED080065DEE2 /* RecentGifManagedMediaId.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F02CD81E97ED080065DEE2 /* RecentGifManagedMediaId.swift */; }; + D0F02CDB1E981D240065DEE2 /* MultiplexedSoftwareVideoNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F02CDA1E981D240065DEE2 /* MultiplexedSoftwareVideoNode.swift */; }; D0F3A8AB1E82D83E00B4C64C /* TelegramAccountAuxiliaryMethods.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F3A8AA1E82D83E00B4C64C /* TelegramAccountAuxiliaryMethods.swift */; }; D0F3A8B61E83120A00B4C64C /* FetchResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F3A8B51E83120A00B4C64C /* FetchResource.swift */; }; D0F3A8B81E83125C00B4C64C /* MediaResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F3A8B71E83125C00B4C64C /* MediaResources.swift */; }; @@ -503,6 +542,8 @@ 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 */; }; + D0FA34FF1EA5834C00E56FFA /* ItemListControllerSegmentedTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA34FE1EA5834C00E56FFA /* ItemListControllerSegmentedTitleView.swift */; }; + D0FA35011EA6127000E56FFA /* StorageUsageController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA35001EA6127000E56FFA /* StorageUsageController.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, ); }; }; @@ -521,6 +562,13 @@ /* Begin PBXFileReference section */ D00219031DDCC86400BE708A /* PerformanceSpinner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PerformanceSpinner.swift; sourceTree = ""; }; D00219051DDD1C9E00BE708A /* ImageContainingNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageContainingNode.swift; sourceTree = ""; }; + D002A0D01E9B99F500A81812 /* SoftwareVideoSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SoftwareVideoSource.swift; sourceTree = ""; }; + D002A0D21E9BBE6700A81812 /* MultiplexedSoftwareVideoSourceManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultiplexedSoftwareVideoSourceManager.swift; sourceTree = ""; }; + D002A0D41E9BD48400A81812 /* SampleBufferPool.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SampleBufferPool.swift; sourceTree = ""; }; + D002A0D61E9BD92100A81812 /* MultiplexedVideoNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultiplexedVideoNode.swift; sourceTree = ""; }; + D002A0D81E9BEC8100A81812 /* SoftwareVideoLayerFrameManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SoftwareVideoLayerFrameManager.swift; sourceTree = ""; }; + D002A0DA1E9C190700A81812 /* SoftwareVideoThumbnailLayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SoftwareVideoThumbnailLayer.swift; sourceTree = ""; }; + D002A0DC1E9CD52A00A81812 /* ChatMediaInputRecentGifsItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMediaInputRecentGifsItem.swift; sourceTree = ""; }; D003702D1DA43052004308D3 /* ItemListAvatarAndNameItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListAvatarAndNameItem.swift; sourceTree = ""; }; D003702F1DA43077004308D3 /* ItemListItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListItem.swift; sourceTree = ""; }; D00370311DA46C06004308D3 /* ItemListTextWithLabelItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListTextWithLabelItem.swift; sourceTree = ""; }; @@ -537,6 +585,10 @@ D00C7CF61E37BF680080C3D5 /* SecretChatKeyVisualization.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SecretChatKeyVisualization.m; sourceTree = ""; }; D00D34361E6E14E30057B307 /* ChatMessageThrottledProcessingManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMessageThrottledProcessingManager.swift; sourceTree = ""; }; D00DBBDC1E65650800DB5485 /* ChatReportPeerTitlePanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatReportPeerTitlePanelNode.swift; sourceTree = ""; }; + D00DE6971E8E8E33003F0D76 /* ShareController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShareController.swift; sourceTree = ""; }; + D00DE6991E8E8E43003F0D76 /* ShareControllerNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShareControllerNode.swift; sourceTree = ""; }; + D00DE69B1E8E8E97003F0D76 /* ShareControllerPeerGridItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShareControllerPeerGridItem.swift; sourceTree = ""; }; + D00DE6AC1E8EB2D4003F0D76 /* ShareActionButtonNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShareActionButtonNode.swift; sourceTree = ""; }; D00E15251DDBD4E700ACF65C /* LegacyCamera.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyCamera.swift; sourceTree = ""; }; D0105D591D80B957008755D8 /* ChatChannelSubscriberInputPanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatChannelSubscriberInputPanelNode.swift; sourceTree = ""; }; D0127A0C1E6424AC003BFF2E /* ChatPinnedMessageTitlePanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatPinnedMessageTitlePanelNode.swift; sourceTree = ""; }; @@ -558,7 +610,7 @@ D01B279A1E39386C0022A4C0 /* SettingsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsController.swift; sourceTree = ""; }; 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 = ""; }; + D01B27A31E394FC90022A4C0 /* PresentationPasscodeSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PresentationPasscodeSettings.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 = ""; }; @@ -588,6 +640,11 @@ D021E0CF1DB413BC00C6B04F /* ChatInputNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInputNode.swift; sourceTree = ""; }; D021E0D11DB4147500C6B04F /* ChatInterfaceInputNodes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInterfaceInputNodes.swift; sourceTree = ""; }; D021E0E41DB55D0A00C6B04F /* ChatMediaInputStickerPackItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMediaInputStickerPackItem.swift; sourceTree = ""; }; + D0223A8F1EA53E6000211D94 /* AutomaticMediaDownloadSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutomaticMediaDownloadSettings.swift; sourceTree = ""; }; + D0223A911EA5420C00211D94 /* GeneratedMediaStoreSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedMediaStoreSettings.swift; sourceTree = ""; }; + D0223A931EA5442C00211D94 /* VoiceCallSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VoiceCallSettings.swift; sourceTree = ""; }; + D0223A951EA54D0D00211D94 /* VoiceCallDataSavingController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VoiceCallDataSavingController.swift; sourceTree = ""; }; + D0223A9D1EA5732300211D94 /* NetworkUsageStatsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkUsageStatsController.swift; sourceTree = ""; }; D02298361E0C34E900707F91 /* ChatMessageBackground.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMessageBackground.swift; sourceTree = ""; }; D023836F1DDF0462004018B6 /* UrlHandling.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UrlHandling.swift; sourceTree = ""; }; D02383721DDF0D8A004018B6 /* ChatInfoTitlePanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatInfoTitlePanelNode.swift; sourceTree = ""; }; @@ -615,6 +672,11 @@ D03ADB4E1D70546B005A521C /* AccessoryPanelNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccessoryPanelNode.swift; sourceTree = ""; }; 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 = ""; }; + D042C6801E8D9A6700C863B0 /* GalleryFooterNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GalleryFooterNode.swift; sourceTree = ""; }; + D042C6851E8DA69D00C863B0 /* GalleryFooterContentNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GalleryFooterContentNode.swift; sourceTree = ""; }; + D042C6871E8DA8C800C863B0 /* GalleryControllerPresentationState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GalleryControllerPresentationState.swift; sourceTree = ""; }; + D042C6891E8DAAB000C863B0 /* ChatItemGalleryFooterContentNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatItemGalleryFooterContentNode.swift; sourceTree = ""; }; + D042C68B1E8DAB7100C863B0 /* ChatItemGalleryItemNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatItemGalleryItemNode.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 = ""; }; @@ -707,6 +769,12 @@ D0561DE71E574C3200E6B9E9 /* ChannelAdminsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelAdminsController.swift; sourceTree = ""; }; D0568AAC1DF198130022E7DA /* AudioWaveformNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioWaveformNode.swift; sourceTree = ""; }; D0568AAE1DF1B3920022E7DA /* HapticFeedback.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HapticFeedback.swift; sourceTree = ""; }; + D0575AEA1E9FD579006F2541 /* ChatListTitleLockView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatListTitleLockView.swift; sourceTree = ""; }; + D0575AEC1E9FF1AD006F2541 /* ChatMediaInputTrendingPane.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMediaInputTrendingPane.swift; sourceTree = ""; }; + D0575AEE1E9FF881006F2541 /* ChatMediaInputTrendingItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMediaInputTrendingItem.swift; sourceTree = ""; }; + D0575AF61EA0ED4F006F2541 /* ChatMessageInstantVideoItemNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMessageInstantVideoItemNode.swift; sourceTree = ""; }; + D0575AF91EA0FDA7006F2541 /* AvatarGalleryController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarGalleryController.swift; sourceTree = ""; }; + D0575AFB1EA104A6006F2541 /* PeerAvatarImageGalleryItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeerAvatarImageGalleryItem.swift; sourceTree = ""; }; D05811931DD5F9380057C769 /* TelegramApplicationContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TelegramApplicationContext.swift; sourceTree = ""; }; D058E0CE1E8AD57300A442DE /* VideoPlayerProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerProxy.swift; sourceTree = ""; }; D05A32DB1E6EFCC2002760B4 /* NumericFormat.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NumericFormat.swift; sourceTree = ""; }; @@ -737,6 +805,7 @@ D075518E1DDA4F9E0073E051 /* SSignalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SSignalKit.framework; path = "../../../../Library/Developer/Xcode/DerivedData/Telegram-iOS-diblohvjozhgaifjcniwdlixlilx/Build/Products/Debug-iphonesimulator/SSignalKit.framework"; sourceTree = ""; }; D07551901DDA4FC70073E051 /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; }; D07551921DDA540F0073E051 /* TelegramInitializeLegacyComponents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TelegramInitializeLegacyComponents.swift; sourceTree = ""; }; + D0760B231E9D015D00F1F3C4 /* PasscodeOptionsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PasscodeOptionsController.swift; sourceTree = ""; }; D07827BC1E004A3400071108 /* ChatListSearchItemHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatListSearchItemHeader.swift; sourceTree = ""; }; D07827C61E01CD5900071108 /* VerticalListContextResultsChatInputPanelButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VerticalListContextResultsChatInputPanelButtonItem.swift; sourceTree = ""; }; D07A7DA21D957671005BCD27 /* ListMessageSnippetItemNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListMessageSnippetItemNode.swift; sourceTree = ""; }; @@ -803,8 +872,17 @@ 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 = ""; }; + D0BE383B1E7C3E51000079AF /* StickerPreviewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StickerPreviewController.swift; sourceTree = ""; }; + D0BE931A1E92DFBA00DCC1E6 /* StickerPreviewControllerNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StickerPreviewControllerNode.swift; sourceTree = ""; }; D0C48F431E81D5110075317D /* ChatEmptyItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatEmptyItem.swift; sourceTree = ""; }; + D0C50DE81E93A07900F62E39 /* libtgvoip.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = libtgvoip.framework; path = "../libtgvoip/build/Debug-iphoneos/libtgvoip.framework"; sourceTree = ""; }; + D0C50E281E93A33700F62E39 /* VoipDynamic.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = VoipDynamic.framework; path = "../../../../Library/Developer/Xcode/DerivedData/Telegram-iOS-diblohvjozhgaifjcniwdlixlilx/Build/Products/Debug-iphoneos/VoipDynamic.framework"; sourceTree = ""; }; + D0C50E371E93CB1500F62E39 /* NotificationContainerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationContainerController.swift; sourceTree = ""; }; + D0C50E391E93CB4300F62E39 /* NotificationContainerControllerNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationContainerControllerNode.swift; sourceTree = ""; }; + D0C50E3B1E93CC2600F62E39 /* NotificationItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationItem.swift; sourceTree = ""; }; + D0C50E3D1E93D09200F62E39 /* NotificationItemContainerNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationItemContainerNode.swift; sourceTree = ""; }; + D0C50E3F1E93D3B000F62E39 /* ChatMessageNotificationItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMessageNotificationItem.swift; sourceTree = ""; }; + D0C50E431E93FCD200F62E39 /* notification.caf */ = {isa = PBXFileReference; lastKnownFileType = file; name = notification.caf; path = TelegramUI/Sounds/notification.caf; 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 = ""; }; @@ -887,6 +965,10 @@ 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 = ""; }; + D0F02CCB1E96EF350065DEE2 /* ChatMediaInputStickerPane.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMediaInputStickerPane.swift; sourceTree = ""; }; + D0F02CCD1E96FACE0065DEE2 /* ChatMediaInputGifPane.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatMediaInputGifPane.swift; sourceTree = ""; }; + D0F02CD81E97ED080065DEE2 /* RecentGifManagedMediaId.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecentGifManagedMediaId.swift; sourceTree = ""; }; + D0F02CDA1E981D240065DEE2 /* MultiplexedSoftwareVideoNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultiplexedSoftwareVideoNode.swift; sourceTree = ""; }; D0F3A8AA1E82D83E00B4C64C /* TelegramAccountAuxiliaryMethods.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TelegramAccountAuxiliaryMethods.swift; sourceTree = ""; }; D0F3A8B51E83120A00B4C64C /* FetchResource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchResource.swift; sourceTree = ""; }; D0F3A8B71E83125C00B4C64C /* MediaResources.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaResources.swift; sourceTree = ""; }; @@ -1018,6 +1100,8 @@ 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 = ""; }; + D0FA34FE1EA5834C00E56FFA /* ItemListControllerSegmentedTitleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListControllerSegmentedTitleView.swift; sourceTree = ""; }; + D0FA35001EA6127000E56FFA /* StorageUsageController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StorageUsageController.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 = ""; }; @@ -1031,7 +1115,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - D0D03B261DECB26D00220C46 /* libopus.a in Frameworks */, + D0C50E471E9459E100F62E39 /* libopus.a in Frameworks */, D07551911DDA4FC70073E051 /* libc++.tbd in Frameworks */, D075518F1DDA4F9E0073E051 /* SSignalKit.framework in Frameworks */, D07551881DDA4BB50073E051 /* TelegramLegacyComponents.framework in Frameworks */, @@ -1071,6 +1155,17 @@ name = "Secret Preview"; sourceTree = ""; }; + D00DE6961E8E8E21003F0D76 /* Share */ = { + isa = PBXGroup; + children = ( + D00DE6971E8E8E33003F0D76 /* ShareController.swift */, + D00DE6991E8E8E43003F0D76 /* ShareControllerNode.swift */, + D00DE69B1E8E8E97003F0D76 /* ShareControllerPeerGridItem.swift */, + D00DE6AC1E8EB2D4003F0D76 /* ShareActionButtonNode.swift */, + ); + name = Share; + sourceTree = ""; + }; D017494F1E1067C00057C89A /* Hashtag Search */ = { isa = PBXGroup; children = ( @@ -1095,6 +1190,7 @@ D01B27981E39144C0022A4C0 /* ItemListController.swift */, D0E305AC1E5BA3E700D7A3A2 /* ItemListControllerEmptyStateItem.swift */, D01B27941E38F3BF0022A4C0 /* ItemListControllerNode.swift */, + D0FA34FE1EA5834C00E56FFA /* ItemListControllerSegmentedTitleView.swift */, ); name = "Item List"; sourceTree = ""; @@ -1113,11 +1209,23 @@ isa = PBXGroup; children = ( D021E0CD1DB4135500C6B04F /* ChatMediaInputNode.swift */, + D0F02CCB1E96EF350065DEE2 /* ChatMediaInputStickerPane.swift */, + D0F02CCD1E96FACE0065DEE2 /* ChatMediaInputGifPane.swift */, D08C367E1DB66A820064C744 /* ChatMediaInputPanelEntries.swift */, D08C36801DB66AAC0064C744 /* ChatMediaInputGridEntries.swift */, D049EAE51E44AD5600A2CD3A /* ChatMediaInputRecentStickerPacksItem.swift */, + D002A0DC1E9CD52A00A81812 /* ChatMediaInputRecentGifsItem.swift */, + D0575AEE1E9FF881006F2541 /* ChatMediaInputTrendingItem.swift */, D021E0E41DB55D0A00C6B04F /* ChatMediaInputStickerPackItem.swift */, D08C36821DB66AD40064C744 /* ChatMediaInputStickerGridItem.swift */, + D0F02CDA1E981D240065DEE2 /* MultiplexedSoftwareVideoNode.swift */, + D002A0D01E9B99F500A81812 /* SoftwareVideoSource.swift */, + D002A0D21E9BBE6700A81812 /* MultiplexedSoftwareVideoSourceManager.swift */, + D002A0D41E9BD48400A81812 /* SampleBufferPool.swift */, + D002A0D61E9BD92100A81812 /* MultiplexedVideoNode.swift */, + D002A0D81E9BEC8100A81812 /* SoftwareVideoLayerFrameManager.swift */, + D002A0DA1E9C190700A81812 /* SoftwareVideoThumbnailLayer.swift */, + D0575AEC1E9FF1AD006F2541 /* ChatMediaInputTrendingPane.swift */, ); name = Media; sourceTree = ""; @@ -1342,6 +1450,14 @@ name = "Country Selection"; sourceTree = ""; }; + D0575AF81EA0FD94006F2541 /* Avatar Gallery */ = { + isa = PBXGroup; + children = ( + D0575AF91EA0FDA7006F2541 /* AvatarGalleryController.swift */, + ); + name = "Avatar Gallery"; + sourceTree = ""; + }; D0736F261DF4D2F300F2C02A /* Telegram Controller */ = { isa = PBXGroup; children = ( @@ -1357,6 +1473,7 @@ D073CE611DCBBE09007511FD /* Sounds */ = { isa = PBXGroup; children = ( + D0C50E431E93FCD200F62E39 /* notification.caf */, D073CE621DCBBE5D007511FD /* MessageSent.caf */, ); name = Sounds; @@ -1425,7 +1542,10 @@ isa = PBXGroup; children = ( D01B279E1E394BD70022A4C0 /* InAppNotificationSettings.swift */, - D01B27A31E394FC90022A4C0 /* SecuritySettings.swift */, + D01B27A31E394FC90022A4C0 /* PresentationPasscodeSettings.swift */, + D0223A8F1EA53E6000211D94 /* AutomaticMediaDownloadSettings.swift */, + D0223A911EA5420C00211D94 /* GeneratedMediaStoreSettings.swift */, + D0223A931EA5442C00211D94 /* VoiceCallSettings.swift */, ); name = Settings; sourceTree = ""; @@ -1465,6 +1585,8 @@ D08D45281D5E340200A7428A /* Frameworks */ = { isa = PBXGroup; children = ( + D0C50E281E93A33700F62E39 /* VoipDynamic.framework */, + D0C50DE81E93A07900F62E39 /* libtgvoip.framework */, D0D03B251DECB26D00220C46 /* libopus.a */, D07551901DDA4FC70073E051 /* libc++.tbd */, D075518E1DDA4F9E0073E051 /* SSignalKit.framework */, @@ -1503,6 +1625,7 @@ D099EA261DE765DB001AF5A8 /* ManagedMediaId.swift */, D099EA2C1DE76782001AF5A8 /* PeerMessageManagedMediaId.swift */, D099EA2E1DE775BB001AF5A8 /* ChatContextResultManagedMediaId.swift */, + D0F02CD81E97ED080065DEE2 /* RecentGifManagedMediaId.swift */, D099EA281DE76655001AF5A8 /* ManagedVideoNode.swift */, D0F69CD61D6B87D30046BCD6 /* MediaManager.swift */, D0D03AE21DECACB700220C46 /* ManagedAudioSession.swift */, @@ -1581,6 +1704,18 @@ name = "Contact Selection"; sourceTree = ""; }; + D0C50E361E93CAF200F62E39 /* Notifications */ = { + isa = PBXGroup; + children = ( + D0C50E371E93CB1500F62E39 /* NotificationContainerController.swift */, + D0C50E391E93CB4300F62E39 /* NotificationContainerControllerNode.swift */, + D0C50E3D1E93D09200F62E39 /* NotificationItemContainerNode.swift */, + D0C50E3B1E93CC2600F62E39 /* NotificationItem.swift */, + D0C50E3F1E93D3B000F62E39 /* ChatMessageNotificationItem.swift */, + ); + name = Notifications; + sourceTree = ""; + }; D0C932341E0988AD0074F044 /* Button Keyboard */ = { isa = PBXGroup; children = ( @@ -1614,6 +1749,9 @@ isa = PBXGroup; children = ( D0C9323B1E0B4AE90074F044 /* DataAndStorageSettingsController.swift */, + D0223A951EA54D0D00211D94 /* VoiceCallDataSavingController.swift */, + D0223A9D1EA5732300211D94 /* NetworkUsageStatsController.swift */, + D0FA35001EA6127000E56FFA /* StorageUsageController.swift */, ); name = "Data and Storage"; sourceTree = ""; @@ -1714,7 +1852,8 @@ D0D748051E7AF63800F4B1F6 /* StickerPackPreviewController.swift */, D0D748071E7AF64400F4B1F6 /* StickerPackPreviewControllerNode.swift */, D0D7480E1E7B1BD600F4B1F6 /* StickerPackPreviewGridItem.swift */, - D0BE383B1E7C3E51000079AF /* StickerPackGalleryController.swift */, + D0BE383B1E7C3E51000079AF /* StickerPreviewController.swift */, + D0BE931A1E92DFBA00DCC1E6 /* StickerPreviewControllerNode.swift */, ); name = Stickers; sourceTree = ""; @@ -2021,6 +2160,7 @@ D0F69DF61D6B8A720046BCD6 /* Chat List */, D017494F1E1067C00057C89A /* Hashtag Search */, D0F69E0D1D6B8AB90046BCD6 /* Chat */, + D00DE6961E8E8E21003F0D76 /* Share */, D0F69E4E1D6B8BB90046BCD6 /* Media */, D0F69E6C1D6B8C220046BCD6 /* Contacts */, D0BC38681E3FB92B0044D6FE /* Compose */, @@ -2028,6 +2168,7 @@ D0EE97131D88BB1A006C18E1 /* Peer Info */, D0D2689B1D79D31500C422DA /* Peer Selection */, D0F69E791D6B8C3B0046BCD6 /* Settings */, + D0C50E361E93CAF200F62E39 /* Notifications */, ); name = Controllers; sourceTree = ""; @@ -2052,6 +2193,7 @@ D0F69DF81D6B8A880046BCD6 /* ChatListController.swift */, D0F69DF91D6B8A880046BCD6 /* ChatListControllerNode.swift */, D01749611E11DB240057C89A /* NetworkStatusTitleView.swift */, + D0575AEA1E9FD579006F2541 /* ChatListTitleLockView.swift */, D0F69E051D6B8A8B0046BCD6 /* Search */, ); name = "Chat List"; @@ -2123,6 +2265,7 @@ D0F69E271D6B8B030046BCD6 /* ChatMessageMediaBubbleContentNode.swift */, D0F69E281D6B8B030046BCD6 /* ChatMessageReplyInfoNode.swift */, D0F69E291D6B8B030046BCD6 /* ChatMessageStickerItemNode.swift */, + D0575AF61EA0ED4F006F2541 /* ChatMessageInstantVideoItemNode.swift */, D0F69E2A1D6B8B030046BCD6 /* ChatMessageTextBubbleContentNode.swift */, D0F69E2B1D6B8B030046BCD6 /* ChatMessageWebpageBubbleContentNode.swift */, D0F69E2C1D6B8B030046BCD6 /* ChatUnreadItem.swift */, @@ -2172,6 +2315,7 @@ isa = PBXGroup; children = ( D0B7F8DF1D8A17D20045D939 /* Collection */, + D0575AF81EA0FD94006F2541 /* Avatar Gallery */, D0F69E4F1D6B8BC40046BCD6 /* Gallery */, D0F69E671D6B8C030046BCD6 /* Map Input */, D07827CC1E03F32C00071108 /* Instant Page */, @@ -2185,9 +2329,12 @@ children = ( D0F69E501D6B8BDA0046BCD6 /* GalleryController.swift */, D0F69E511D6B8BDA0046BCD6 /* GalleryControllerNode.swift */, + D042C6871E8DA8C800C863B0 /* GalleryControllerPresentationState.swift */, D0F69E521D6B8BDA0046BCD6 /* GalleryItem.swift */, D0F69E531D6B8BDA0046BCD6 /* GalleryItemNode.swift */, D0F69E541D6B8BDA0046BCD6 /* GalleryPagerNode.swift */, + D042C6801E8D9A6700C863B0 /* GalleryFooterNode.swift */, + D042C6851E8DA69D00C863B0 /* GalleryFooterContentNode.swift */, D00C7CDA1E3776CA0080C3D5 /* Secret Preview */, D0F69E5A1D6B8BDD0046BCD6 /* Items */, ); @@ -2203,6 +2350,9 @@ D0F69E5E1D6B8BF90046BCD6 /* ChatVideoGalleryItem.swift */, D0F69E5F1D6B8BF90046BCD6 /* ChatVideoGalleryItemScrubberView.swift */, D0F69E601D6B8BF90046BCD6 /* ZoomableContentGalleryItemNode.swift */, + D042C6891E8DAAB000C863B0 /* ChatItemGalleryFooterContentNode.swift */, + D042C68B1E8DAB7100C863B0 /* ChatItemGalleryItemNode.swift */, + D0575AFB1EA104A6006F2541 /* PeerAvatarImageGalleryItem.swift */, ); name = Items; sourceTree = ""; @@ -2315,6 +2465,7 @@ D01C2AAA1E75E010001F6F9A /* TwoStepVerificationUnlockController.swift */, D0FA0ABE1E76E17F005BB9B7 /* TwoStepVerificationPasswordEntryController.swift */, D0FA0AC01E7725AA005BB9B7 /* TwoStepVerificationResetController.swift */, + D0760B231E9D015D00F1F3C4 /* PasscodeOptionsController.swift */, ); name = "Privacy and Security"; sourceTree = ""; @@ -2542,6 +2693,7 @@ D073CE631DCBBE5D007511FD /* MessageSent.caf in Resources */, D04BB3591E48797500650E93 /* ic_cam_lens@2x.png in Resources */, D04BB3631E48797500650E93 /* powerful_mask@2x.png in Resources */, + D0C50E441E93FCD200F62E39 /* notification.caf in Resources */, D04BB3531E48797500650E93 /* fast_arrow_shadow@2x.png in Resources */, D04BB3691E48797500650E93 /* start_arrow_ipad@2x.png in Resources */, ); @@ -2561,7 +2713,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D0BE383C1E7C3E51000079AF /* StickerPackGalleryController.swift in Sources */, + D042C68A1E8DAAB000C863B0 /* ChatItemGalleryFooterContentNode.swift in Sources */, + D0BE383C1E7C3E51000079AF /* StickerPreviewController.swift in Sources */, + D042C6811E8D9A6700C863B0 /* GalleryFooterNode.swift in Sources */, D01749621E11DB240057C89A /* NetworkStatusTitleView.swift in Sources */, D0B417C31D7DE54E004562A4 /* ChatPresentationInterfaceState.swift in Sources */, D00B3F9E1E3A4847003872C3 /* ItemListSectionHeaderItem.swift in Sources */, @@ -2574,6 +2728,7 @@ D0D03B1B1DECB0FE00220C46 /* info.c in Sources */, D0215D4A1E041CAF001A0B1E /* InstantPageMediaItem.swift in Sources */, D0C48F441E81D5110075317D /* ChatEmptyItem.swift in Sources */, + D002A0D71E9BD92100A81812 /* MultiplexedVideoNode.swift in Sources */, D087751E1E3F579300A97350 /* CounterContollerTitleView.swift in Sources */, D050F2131E48B61500988324 /* PhoneInputNode.swift in Sources */, D08775141E3F4A7700A97350 /* ContactListNameIndexHeader.swift in Sources */, @@ -2612,26 +2767,33 @@ D01F66131DE8903300345CBE /* ChatTextInputAudioRecordingButton.swift in Sources */, D0F69E341D6B8B030046BCD6 /* ChatMessageForwardInfoNode.swift in Sources */, D00C7CF81E37BF680080C3D5 /* SecretChatKeyVisualization.m in Sources */, + D0C50E3C1E93CC2600F62E39 /* NotificationItem.swift in Sources */, D06E4AC41E84806300627D1D /* FetchPhotoLibraryImageResource.swift in Sources */, D0736F251DF4D0E500F2C02A /* TelegramController.swift in Sources */, D021E0AB1E3B9E2700AF709C /* ItemListRevealOptionsNode.swift in Sources */, + D0575AEF1E9FF881006F2541 /* ChatMediaInputTrendingItem.swift in Sources */, D0F69E561D6B8BDA0046BCD6 /* GalleryControllerNode.swift in Sources */, D0F69E4D1D6B8BB20046BCD6 /* ChatMediaActionSheetRollItem.swift in Sources */, + D0575AFC1EA104A6006F2541 /* PeerAvatarImageGalleryItem.swift in Sources */, D0F69E661D6B8BF90046BCD6 /* ZoomableContentGalleryItemNode.swift in Sources */, D0DE77001D92F1EB002B8809 /* ChatTitleView.swift in Sources */, + D002A0D11E9B99F500A81812 /* SoftwareVideoSource.swift in Sources */, D0B843DB1DAAB138005F29E1 /* ItemListPeerActionItem.swift in Sources */, D0F69EA11D6B8E380046BCD6 /* FileResources.swift in Sources */, D0F69D271D6B87D30046BCD6 /* FFMpegAudioFrameDecoder.swift in Sources */, D0DC354C1DE366DE000195EB /* CommandChatInputPanelItem.swift in Sources */, D0613FCD1E60482300202CDB /* ChannelMembersController.swift in Sources */, D073CE651DCBC26B007511FD /* ServiceSoundManager.swift in Sources */, + D0BE931B1E92DFBA00DCC1E6 /* StickerPreviewControllerNode.swift in Sources */, D0F69D521D6B87D30046BCD6 /* MediaPlayer.swift in Sources */, + D0C50E3E1E93D09200F62E39 /* NotificationItemContainerNode.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 */, + D0575AF71EA0ED4F006F2541 /* ChatMessageInstantVideoItemNode.swift in Sources */, D02383791DDF1A4D004018B6 /* ChatRequestInProgressTitlePanelNode.swift in Sources */, D0C932381E09E0EA0074F044 /* ChatBotInfoItem.swift in Sources */, D0DE77291D932923002B8809 /* GridMessageSelectionNode.swift in Sources */, @@ -2645,6 +2807,7 @@ D0F69E161D6B8ACF0046BCD6 /* ChatHistoryEntry.swift in Sources */, D021E0A91E3AACA200AF709C /* ItemListEditableItem.swift in Sources */, D021E0CE1DB4135500C6B04F /* ChatMediaInputNode.swift in Sources */, + D042C68C1E8DAB7100C863B0 /* ChatItemGalleryItemNode.swift in Sources */, D0F69DE01D6B8A420046BCD6 /* ListControllerButtonItem.swift in Sources */, D0F69E0C1D6B8AB10046BCD6 /* HorizontalPeerItem.swift in Sources */, D0F69E551D6B8BDA0046BCD6 /* GalleryController.swift in Sources */, @@ -2677,6 +2840,7 @@ D01B279D1E394A500022A4C0 /* NotificationsAndSounds.swift in Sources */, D0F69E3D1D6B8B030046BCD6 /* ChatMessageWebpageBubbleContentNode.swift in Sources */, D04BB3731E48797500650E93 /* RMIntroPageView.m in Sources */, + D0760B241E9D015D00F1F3C4 /* PasscodeOptionsController.swift in Sources */, D08C36811DB66AAC0064C744 /* ChatMediaInputGridEntries.swift in Sources */, D099EA1F1DE7450B001AF5A8 /* HorizontalListContextResultsChatInputContextPanelNode.swift in Sources */, D0DF0C9E1D82141F008AEB01 /* ChatInterfaceInputContexts.swift in Sources */, @@ -2685,6 +2849,7 @@ D0D2686E1D7898A900C422DA /* ChatMessageSelectionNode.swift in Sources */, D0EFD8961DDE8249009E508A /* LegacyLocationPicker.swift in Sources */, D0613FD51E6064D200202CDB /* ConvertToSupergroupController.swift in Sources */, + D042C6861E8DA69D00C863B0 /* GalleryFooterContentNode.swift in Sources */, D04BB3771E48797500650E93 /* RMLoginViewController.m in Sources */, D0F69E8B1D6B8C850046BCD6 /* FFMpegSwResample.m in Sources */, D0B843921DA7F13E005F29E1 /* ItemListDisclosureItem.swift in Sources */, @@ -2718,17 +2883,23 @@ D0F3A8AB1E82D83E00B4C64C /* TelegramAccountAuxiliaryMethods.swift in Sources */, D0736F2A1DF4D5FF00F2C02A /* MediaNavigationAccessoryPanel.swift in Sources */, D087751C1E3F542500A97350 /* ContactMultiselectionControllerNode.swift in Sources */, + D0223A9E1EA5732300211D94 /* NetworkUsageStatsController.swift in Sources */, D0E7A1BD1D8C246D00C37A6F /* ChatHistoryListNode.swift in Sources */, D049EAE21E447AD500A2CD3A /* HorizontalStickersChatContextPanelNode.swift in Sources */, D0F69E371D6B8B030046BCD6 /* ChatMessageItem.swift in Sources */, D099EA291DE76655001AF5A8 /* ManagedVideoNode.swift in Sources */, D0215D501E0422C7001A0B1E /* InstantPageWebEmbedItem.swift in Sources */, D0BC38631E3F9EFA0044D6FE /* EditableTokenListNode.swift in Sources */, + D00DE6981E8E8E33003F0D76 /* ShareController.swift in Sources */, D049EAE61E44AD5600A2CD3A /* ChatMediaInputRecentStickerPacksItem.swift in Sources */, D023ED2E1DDB5BEC00BD496D /* LegacyAttachmentMenu.swift in Sources */, + D0F02CCE1E96FACE0065DEE2 /* ChatMediaInputGifPane.swift in Sources */, D02383771DDF16B2004018B6 /* ChatControllerTitlePanelNodeContainer.swift in Sources */, + D042C6881E8DA8C800C863B0 /* GalleryControllerPresentationState.swift in Sources */, D00370321DA46C06004308D3 /* ItemListTextWithLabelItem.swift in Sources */, D00B3FA21E3A983E003872C3 /* ItemListTextItem.swift in Sources */, + D002A0D51E9BD48400A81812 /* SampleBufferPool.swift in Sources */, + D0223A961EA54D0D00211D94 /* VoiceCallDataSavingController.swift in Sources */, D018D3351E6489EC00C5E089 /* CreateChannelController.swift in Sources */, D0DF0C9C1D81FFB2008AEB01 /* ChatInterfaceInputContextPanels.swift in Sources */, D0DE77301D934DEF002B8809 /* ListMessageItem.swift in Sources */, @@ -2742,14 +2913,17 @@ D07CFF7B1DCA24BF00761F81 /* ChatListNodeEntries.swift in Sources */, D0DF0CA11D821B28008AEB01 /* HashtagChatInputPanelItem.swift in Sources */, D021E0D01DB413BC00C6B04F /* ChatInputNode.swift in Sources */, + D002A0DD1E9CD52A00A81812 /* ChatMediaInputRecentGifsItem.swift in Sources */, D0F69E351D6B8B030046BCD6 /* ChatMessageInteractiveFileNode.swift in Sources */, D0A11BFC1E7840750081CE03 /* ChangePhoneNumberController.swift in Sources */, + D002A0DB1E9C190700A81812 /* SoftwareVideoThumbnailLayer.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 */, + D0575AFA1EA0FDA7006F2541 /* AvatarGalleryController.swift in Sources */, D0F69E001D6B8A880046BCD6 /* ChatListControllerNode.swift in Sources */, D04BB37B1E48797500650E93 /* texture_helper.m in Sources */, D0F69EA31D6B8E380046BCD6 /* StickerResources.swift in Sources */, @@ -2761,6 +2935,7 @@ D0F69E621D6B8BF90046BCD6 /* ChatHoleGalleryItem.swift in Sources */, D0F69E331D6B8B030046BCD6 /* ChatMessageFileBubbleContentNode.swift in Sources */, D04BB33A1E48797500650E93 /* shader.c in Sources */, + D0FA34FF1EA5834C00E56FFA /* ItemListControllerSegmentedTitleView.swift in Sources */, D05B72501E720597000BD3AD /* PresentationData.swift in Sources */, D0F69E461D6B8B950046BCD6 /* ChatHistoryNavigationButtonNode.swift in Sources */, D03ADB481D703268005A521C /* ChatInterfaceState.swift in Sources */, @@ -2769,9 +2944,13 @@ D0F69E321D6B8B030046BCD6 /* ChatMessageDateAndStatusNode.swift in Sources */, D0F3A8B61E83120A00B4C64C /* FetchResource.swift in Sources */, D0F69E041D6B8A880046BCD6 /* ChatListSearchItem.swift in Sources */, + D0F02CDB1E981D240065DEE2 /* MultiplexedSoftwareVideoNode.swift in Sources */, + D0223A941EA5442C00211D94 /* VoiceCallSettings.swift in Sources */, D0F69E611D6B8BF90046BCD6 /* ChatDocumentGalleryItem.swift in Sources */, + D0223A921EA5420C00211D94 /* GeneratedMediaStoreSettings.swift in Sources */, D0D748081E7AF64400F4B1F6 /* StickerPackPreviewControllerNode.swift in Sources */, D0F69E0A1D6B8AA60046BCD6 /* ChatListSearchRecentPeersNode.swift in Sources */, + D0C50E401E93D3B000F62E39 /* ChatMessageNotificationItem.swift in Sources */, D00C7CE61E378FD00080C3D5 /* RadialTimeoutNode.swift in Sources */, D0F69E3E1D6B8B030046BCD6 /* ChatUnreadItem.swift in Sources */, D0D03B0D1DECB0FE00220C46 /* opusenc.m in Sources */, @@ -2789,6 +2968,7 @@ D0F69E971D6B8C9B0046BCD6 /* WebP.swift in Sources */, D0F69E2F1D6B8B030046BCD6 /* ChatMessageBubbleContentCalclulateImageCorners.swift in Sources */, D03E5E091E55C49C0029569A /* DebugAccountsController.swift in Sources */, + D00DE69C1E8E8E97003F0D76 /* ShareControllerPeerGridItem.swift in Sources */, D075518B1DDA4D7D0073E051 /* LegacyController.swift in Sources */, D0F69E361D6B8B030046BCD6 /* ChatMessageInteractiveMediaNode.swift in Sources */, D08775191E3F53FC00A97350 /* ContactMultiselectionController.swift in Sources */, @@ -2799,6 +2979,7 @@ D058E0CF1E8AD57300A442DE /* VideoPlayerProxy.swift in Sources */, D0F69E381D6B8B030046BCD6 /* ChatMessageItemView.swift in Sources */, D0D268671D78793B00C422DA /* ChatInterfaceStateNavigationButtons.swift in Sources */, + D002A0D31E9BBE6700A81812 /* MultiplexedSoftwareVideoSourceManager.swift in Sources */, D039EB081DEC725600886EBC /* ChatTextInputAudioRecordingTimeNode.swift in Sources */, D07827BD1E004A3400071108 /* ChatListSearchItemHeader.swift in Sources */, D0F69E901D6B8C850046BCD6 /* RingByteBuffer.swift in Sources */, @@ -2831,7 +3012,9 @@ D0528E581E65773300E2FEF5 /* DeleteChatInputPanelNode.swift in Sources */, D0215D381E040F53001A0B1E /* InstantPageNode.swift in Sources */, D0F69E8C1D6B8C850046BCD6 /* FrameworkBundle.swift in Sources */, + D0FA35011EA6127000E56FFA /* StorageUsageController.swift in Sources */, D0D03B101DECB0FE00220C46 /* wav_io.c in Sources */, + D0F02CD91E97ED080065DEE2 /* RecentGifManagedMediaId.swift in Sources */, D0F69D661D6B87D30046BCD6 /* FFMpegMediaFrameSourceContextHelpers.swift in Sources */, D0B843D91DAAAA0C005F29E1 /* ItemListPeerItem.swift in Sources */, D0F69DD11D6B8A0D0046BCD6 /* SearchDisplayController.swift in Sources */, @@ -2841,6 +3024,7 @@ D0DE77271D932627002B8809 /* ChatHistoryNode.swift in Sources */, D07CFF7D1DCA273400761F81 /* ChatListViewTransition.swift in Sources */, D0DF0C951D81B063008AEB01 /* ChatInterfaceStateContextMenus.swift in Sources */, + D002A0D91E9BEC8100A81812 /* SoftwareVideoLayerFrameManager.swift in Sources */, D04BB36F1E48797500650E93 /* RMGeometry.m in Sources */, D0DE772B1D932E16002B8809 /* PeerMediaCollectionModeSelectionNode.swift in Sources */, D0F69E8F1D6B8C850046BCD6 /* RingBuffer.m in Sources */, @@ -2859,7 +3043,7 @@ D0D03B0E1DECB0FE00220C46 /* picture.c in Sources */, D0486F0A1E523C8500091F0C /* GroupInfoController.swift in Sources */, D04BB33C1E48797500650E93 /* timing.c in Sources */, - D01B27A41E394FC90022A4C0 /* SecuritySettings.swift in Sources */, + D01B27A41E394FC90022A4C0 /* PresentationPasscodeSettings.swift in Sources */, D050F2181E48D9EA00988324 /* AuthorizationSequenceCountrySelectionControllerNode.swift in Sources */, D01C2AAD1E768404001F6F9A /* Markdown.swift in Sources */, D049EAF31E44DE2500A2CD3A /* AuthorizationSequenceController.swift in Sources */, @@ -2875,6 +3059,7 @@ D01B27951E38F3BF0022A4C0 /* ItemListControllerNode.swift in Sources */, D0F69E6A1D6B8C160046BCD6 /* MapInputController.swift in Sources */, D00219041DDCC86400BE708A /* PerformanceSpinner.swift in Sources */, + D0C50E381E93CB1500F62E39 /* NotificationContainerController.swift in Sources */, D01C2AAB1E75E010001F6F9A /* TwoStepVerificationUnlockController.swift in Sources */, D050F2161E48D9E000988324 /* AuthorizationSequenceCountrySelectionController.swift in Sources */, D0561DDF1E56FE8200E6B9E9 /* ItemListSingleLineInputItem.swift in Sources */, @@ -2882,13 +3067,16 @@ D05A32DC1E6EFCC2002760B4 /* NumericFormat.swift in Sources */, D04BB32C1E48797500650E93 /* animations.c in Sources */, D049EAEE1E44BB3200A2CD3A /* ChatListRecentPeersListItem.swift in Sources */, + D0575AED1E9FF1AD006F2541 /* ChatMediaInputTrendingPane.swift in Sources */, D04BB2BB1E44EA2400650E93 /* AuthorizationSequenceSplashControllerNode.swift in Sources */, D0215D581E04302E001A0B1E /* InstantPageTileNode.swift in Sources */, D0215D521E0423EE001A0B1E /* InstantPageShapeItem.swift in Sources */, D0561DE81E574C3200E6B9E9 /* ChannelAdminsController.swift in Sources */, D0F69DE51D6B8A420046BCD6 /* ListControllerSpacerItem.swift in Sources */, + D00DE6AD1E8EB2D4003F0D76 /* ShareActionButtonNode.swift in Sources */, D0BA6F851D784ECD0034826E /* ChatInterfaceStateInputPanels.swift in Sources */, D0F69DE11D6B8A420046BCD6 /* ListControllerDisclosureActionItem.swift in Sources */, + D0F02CCC1E96EF350065DEE2 /* ChatMediaInputStickerPane.swift in Sources */, D0F69E301D6B8B030046BCD6 /* ChatMessageBubbleContentNode.swift in Sources */, D0F69DC31D6B89DA0046BCD6 /* TextNode.swift in Sources */, D04BB3791E48797500650E93 /* RMRootViewController.m in Sources */, @@ -2926,6 +3114,7 @@ D0F69DA51D6B87EC0046BCD6 /* MediaTrackFrameDecoder.swift in Sources */, D023ED321DDB60CF00BD496D /* LegacyNavigationController.swift in Sources */, D0F69E2D1D6B8B030046BCD6 /* ChatMessageActionItemNode.swift in Sources */, + D0575AEB1E9FD579006F2541 /* ChatListTitleLockView.swift in Sources */, D0D03B081DECB0FE00220C46 /* diag_range.c in Sources */, D0BC387F1E40F1CF0044D6FE /* ContactSelectionController.swift in Sources */, D0F69DE21D6B8A420046BCD6 /* ListControllerGroupableItem.swift in Sources */, @@ -2940,6 +3129,7 @@ D0F69DDF1D6B8A420046BCD6 /* ListController.swift in Sources */, D0127A0D1E6424AC003BFF2E /* ChatPinnedMessageTitlePanelNode.swift in Sources */, D0F69E3A1D6B8B030046BCD6 /* ChatMessageReplyInfoNode.swift in Sources */, + D0223A901EA53E6000211D94 /* AutomaticMediaDownloadSettings.swift in Sources */, D0177B841DFB095000A5083A /* FileMediaResourceStatus.swift in Sources */, D03922A71DF70E3F000F2CE9 /* MediaPlayerScrubbingNode.swift in Sources */, D099EA271DE765DB001AF5A8 /* ManagedMediaId.swift in Sources */, @@ -2947,6 +3137,7 @@ D0736F2C1DF4DC2400F2C02A /* MediaNavigationAccessoryContainerNode.swift in Sources */, D0215D3C1E041014001A0B1E /* InstantPageItem.swift in Sources */, D0E35A071DE4803400BC6096 /* VerticalListContextResultsChatInputContextPanelNode.swift in Sources */, + D00DE69A1E8E8E43003F0D76 /* ShareControllerNode.swift in Sources */, D0D03AE51DECAE8900220C46 /* ManagedAudioRecorder.swift in Sources */, D05A32DE1E6F0097002760B4 /* PrivacyAndSecurityController.swift in Sources */, D0EE971A1D88BCA0006C18E1 /* ChatInfo.swift in Sources */, @@ -2960,6 +3151,7 @@ D0215D481E041B90001A0B1E /* InstantPageAnchorItem.swift in Sources */, D0F69DCF1D6B8A0D0046BCD6 /* SearchBarNode.swift in Sources */, D0F69DE41D6B8A420046BCD6 /* ListControllerNode.swift in Sources */, + D0C50E3A1E93CB4300F62E39 /* NotificationContainerControllerNode.swift in Sources */, D00E15261DDBD4E700ACF65C /* LegacyCamera.swift in Sources */, D0F53BF71E79593500117362 /* AuthorizationSequenceSignUpController.swift in Sources */, D0528E6D1E65DE3B00E2FEF5 /* WebpagePreviewAccessoryPanelNode.swift in Sources */, @@ -3202,6 +3394,7 @@ CLANG_MODULES_AUTOLINK = NO; CODE_SIGN_IDENTITY = ""; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = X834Q8SBVP; DYLIB_COMPATIBILITY_VERSION = 1; diff --git a/TelegramUI/ArhivedStickerPacksController.swift b/TelegramUI/ArhivedStickerPacksController.swift index 505127a6ff..058af3efdc 100644 --- a/TelegramUI/ArhivedStickerPacksController.swift +++ b/TelegramUI/ArhivedStickerPacksController.swift @@ -313,7 +313,7 @@ public func archivedStickerPacksController(account: Account) -> ViewController { emptyStateItem = ItemListLoadingIndicatorEmptyStateItem() } - let controllerState = ItemListControllerState(title: "Archived Stickers", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) + let controllerState = ItemListControllerState(title: .text("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)) diff --git a/TelegramUI/AuthorizationSequenceController.swift b/TelegramUI/AuthorizationSequenceController.swift index 2502de4bef..96e19dccfa 100644 --- a/TelegramUI/AuthorizationSequenceController.swift +++ b/TelegramUI/AuthorizationSequenceController.swift @@ -12,11 +12,6 @@ public final class AuthorizationSequenceController: NavigationController { private var stateDisposable: Disposable? private let actionDisposable = MetaDisposable() - let _authorizedAccount = Promise() - public var authorizedAccount: Signal { - return self._authorizedAccount.get() - } - public init(account: UnauthorizedAccount) { self.account = account @@ -251,19 +246,12 @@ public final class AuthorizationSequenceController: NavigationController { self.setViewControllers([self.splashController(), self.phoneEntryController(countryCode: countryCode, number: number)], animated: !self.viewControllers.isEmpty) case let .confirmationCodeEntry(number, type, _, timeout, nextType): self.setViewControllers([self.splashController(), self.codeEntryController(number: number, type: type, nextType: nextType, timeout: timeout)], animated: !self.viewControllers.isEmpty) - case let .passwordEntry(hint): + 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(apiId: self.account.apiId, id: self.account.id, supplementary: false, appGroupPath: self.account.appGroupPath, testingEnvironment: self.account.testingEnvironment, auxiliaryMethods: telegramAccountAuxiliaryMethods) |> mapToSignal { account -> Signal in - if case let .right(authorizedAccount) = account { - return .single(authorizedAccount) - } else { - return .complete() - } - }) } } } diff --git a/TelegramUI/AutomaticMediaDownloadSettings.swift b/TelegramUI/AutomaticMediaDownloadSettings.swift new file mode 100644 index 0000000000..7c8c56c1ec --- /dev/null +++ b/TelegramUI/AutomaticMediaDownloadSettings.swift @@ -0,0 +1,159 @@ +import Foundation +import Postbox +import SwiftSignalKit + +public struct AutomaticMediaDownloadCategoryPeers: Coding, Equatable { + public let privateChats: Bool + public let groupsAndChannels: Bool + + public init(privateChats: Bool, groupsAndChannels: Bool) { + self.privateChats = privateChats + self.groupsAndChannels = groupsAndChannels + } + + public init(decoder: Decoder) { + self.privateChats = (decoder.decodeInt32ForKey("p") as Int32) != 0 + self.groupsAndChannels = (decoder.decodeInt32ForKey("g") as Int32) != 0 + } + + public func encode(_ encoder: Encoder) { + encoder.encodeInt32(self.privateChats ? 1 : 0, forKey: "p") + encoder.encodeInt32(self.groupsAndChannels ? 1 : 0, forKey: "g") + } + + public func withUpdatedPrivateChats(_ privateChats: Bool) -> AutomaticMediaDownloadCategoryPeers { + return AutomaticMediaDownloadCategoryPeers(privateChats: privateChats, groupsAndChannels: self.groupsAndChannels) + } + + public func withUpdatedGroupsAndChannels(_ groupsAndChannels: Bool) -> AutomaticMediaDownloadCategoryPeers { + return AutomaticMediaDownloadCategoryPeers(privateChats: self.privateChats, groupsAndChannels: groupsAndChannels) + } + + public static func ==(lhs: AutomaticMediaDownloadCategoryPeers, rhs: AutomaticMediaDownloadCategoryPeers) -> Bool { + if lhs.privateChats != rhs.privateChats { + return false + } + if lhs.groupsAndChannels != rhs.groupsAndChannels { + return false + } + return true + } +} + +public struct AutomaticMediaDownloadCategories: Coding, Equatable { + public let photo: AutomaticMediaDownloadCategoryPeers + public let voice: AutomaticMediaDownloadCategoryPeers + public let instantVideo: AutomaticMediaDownloadCategoryPeers + public let gif: AutomaticMediaDownloadCategoryPeers + + public init(photo: AutomaticMediaDownloadCategoryPeers, voice: AutomaticMediaDownloadCategoryPeers, instantVideo: AutomaticMediaDownloadCategoryPeers, gif: AutomaticMediaDownloadCategoryPeers) { + self.photo = photo + self.voice = voice + self.instantVideo = instantVideo + self.gif = gif + } + + public init(decoder: Decoder) { + self.photo = decoder.decodeObjectForKey("p", decoder: { AutomaticMediaDownloadCategoryPeers(decoder: $0) }) as! AutomaticMediaDownloadCategoryPeers + self.voice = decoder.decodeObjectForKey("v", decoder: { AutomaticMediaDownloadCategoryPeers(decoder: $0) }) as! AutomaticMediaDownloadCategoryPeers + self.instantVideo = decoder.decodeObjectForKey("iv", decoder: { AutomaticMediaDownloadCategoryPeers(decoder: $0) }) as! AutomaticMediaDownloadCategoryPeers + self.gif = decoder.decodeObjectForKey("g", decoder: { AutomaticMediaDownloadCategoryPeers(decoder: $0) }) as! AutomaticMediaDownloadCategoryPeers + } + + public func encode(_ encoder: Encoder) { + encoder.encodeObject(self.photo, forKey: "p") + encoder.encodeObject(self.voice, forKey: "v") + encoder.encodeObject(self.instantVideo, forKey: "iv") + encoder.encodeObject(self.gif, forKey: "g") + } + + public func withUpdatedPhoto(_ photo: AutomaticMediaDownloadCategoryPeers) -> AutomaticMediaDownloadCategories { + return AutomaticMediaDownloadCategories(photo: photo, voice: self.voice, instantVideo: self.instantVideo, gif: self.gif) + } + + public func withUpdatedVoice(_ voice: AutomaticMediaDownloadCategoryPeers) -> AutomaticMediaDownloadCategories { + return AutomaticMediaDownloadCategories(photo: self.photo, voice: voice, instantVideo: self.instantVideo, gif: self.gif) + } + + public func withUpdatedInstantVideo(_ instantVideo: AutomaticMediaDownloadCategoryPeers) -> AutomaticMediaDownloadCategories { + return AutomaticMediaDownloadCategories(photo: self.photo, voice: self.voice, instantVideo: instantVideo, gif: self.gif) + } + + public func withUpdatedGif(_ gif: AutomaticMediaDownloadCategoryPeers) -> AutomaticMediaDownloadCategories { + return AutomaticMediaDownloadCategories(photo: self.photo, voice: self.voice, instantVideo: self.instantVideo, gif: gif) + } + + public static func ==(lhs: AutomaticMediaDownloadCategories, rhs: AutomaticMediaDownloadCategories) -> Bool { + if lhs.photo != rhs.photo { + return false + } + if lhs.voice != rhs.voice { + return false + } + if lhs.instantVideo != rhs.instantVideo { + return false + } + if lhs.gif != rhs.gif { + return false + } + return true + } +} + +public struct AutomaticMediaDownloadSettings: PreferencesEntry, Equatable { + public let categories: AutomaticMediaDownloadCategories + public let saveIncomingPhotos: Bool + + public static var defaultSettings: AutomaticMediaDownloadSettings { + return AutomaticMediaDownloadSettings(categories: AutomaticMediaDownloadCategories(photo: AutomaticMediaDownloadCategoryPeers(privateChats: true, groupsAndChannels: true), voice: AutomaticMediaDownloadCategoryPeers(privateChats: true, groupsAndChannels: true), instantVideo: AutomaticMediaDownloadCategoryPeers(privateChats: true, groupsAndChannels: true), gif: AutomaticMediaDownloadCategoryPeers(privateChats: true, groupsAndChannels: true)), saveIncomingPhotos: false) + } + + init(categories: AutomaticMediaDownloadCategories, saveIncomingPhotos: Bool) { + self.categories = categories + self.saveIncomingPhotos = saveIncomingPhotos + } + + public init(decoder: Decoder) { + self.categories = decoder.decodeObjectForKey("c", decoder: { AutomaticMediaDownloadCategories(decoder: $0) }) as! AutomaticMediaDownloadCategories + self.saveIncomingPhotos = (decoder.decodeInt32ForKey("siph") as Int32) != 0 + } + + public func encode(_ encoder: Encoder) { + encoder.encodeObject(self.categories, forKey: "c") + encoder.encodeInt32(self.saveIncomingPhotos ? 1 : 0, forKey: "siph") + } + + public func isEqual(to: PreferencesEntry) -> Bool { + if let to = to as? AutomaticMediaDownloadSettings { + return self == to + } else { + return false + } + } + + public static func ==(lhs: AutomaticMediaDownloadSettings, rhs: AutomaticMediaDownloadSettings) -> Bool { + return lhs.categories == rhs.categories && lhs.saveIncomingPhotos == rhs.saveIncomingPhotos + } + + func withUpdatedCategories(_ categories: AutomaticMediaDownloadCategories) -> AutomaticMediaDownloadSettings { + return AutomaticMediaDownloadSettings(categories: categories, saveIncomingPhotos: self.saveIncomingPhotos) + } + + func withUpdatedSaveIncomingPhotos(_ saveIncomingPhotos: Bool) -> AutomaticMediaDownloadSettings { + return AutomaticMediaDownloadSettings(categories: self.categories, saveIncomingPhotos: saveIncomingPhotos) + } +} + +func updateMediaDownloadSettingsInteractively(postbox: Postbox, _ f: @escaping (AutomaticMediaDownloadSettings) -> AutomaticMediaDownloadSettings) -> Signal { + return postbox.modify { modifier -> Void in + modifier.updatePreferencesEntry(key: ApplicationSpecificPreferencesKeys.automaticMediaDownloadSettings, { entry in + let currentSettings: AutomaticMediaDownloadSettings + if let entry = entry as? AutomaticMediaDownloadSettings { + currentSettings = entry + } else { + currentSettings = AutomaticMediaDownloadSettings.defaultSettings + } + return f(currentSettings) + }) + } +} diff --git a/TelegramUI/AvatarGalleryController.swift b/TelegramUI/AvatarGalleryController.swift new file mode 100644 index 0000000000..08fcdc3c23 --- /dev/null +++ b/TelegramUI/AvatarGalleryController.swift @@ -0,0 +1,305 @@ +import Foundation +import Display +import QuickLook +import Postbox +import SwiftSignalKit +import AsyncDisplayKit +import TelegramCore + +enum AvatarGalleryEntry: Equatable { + case topImage([TelegramMediaImageRepresentation]) + case image(TelegramMediaImage) + + var representations: [TelegramMediaImageRepresentation] { + switch self { + case let .topImage(representations): + return representations + case let.image(image): + return image.representations + } + } + + static func ==(lhs: AvatarGalleryEntry, rhs: AvatarGalleryEntry) -> Bool { + switch lhs { + case let .topImage(lhsRepresentations): + if case let .topImage(rhsRepresentations) = rhs, lhsRepresentations == rhsRepresentations { + return true + } else { + return false + } + case let .image(lhsImage): + if case let .image(rhsImage) = rhs, lhsImage.isEqual(rhsImage) { + return true + } else { + return false + } + } + } +} + +final class AvatarGalleryControllerPresentationArguments { + let transitionArguments: (AvatarGalleryEntry) -> GalleryTransitionArguments? + + init(transitionArguments: @escaping (AvatarGalleryEntry) -> GalleryTransitionArguments?) { + self.transitionArguments = transitionArguments + } +} + +class AvatarGalleryController: ViewController { + private var galleryNode: GalleryControllerNode { + return self.displayNode as! GalleryControllerNode + } + + private let account: Account + + private let _ready = Promise() + override var ready: Promise { + return self._ready + } + private var didSetReady = false + + private let disposable = MetaDisposable() + + private var entries: [AvatarGalleryEntry] = [] + private var centralEntryIndex: Int? + + private let centralItemTitle = Promise() + private let centralItemTitleView = Promise() + private let centralItemNavigationStyle = Promise() + private let centralItemFooterContentNode = Promise() + private let centralItemAttributesDisposable = DisposableSet(); + + private let _hiddenMedia = Promise(nil) + var hiddenMedia: Signal { + return self._hiddenMedia.get() + } + + private let replaceRootController: (ViewController, ValuePromise?) -> Void + + init(account: Account, peer: Peer, replaceRootController: @escaping (ViewController, ValuePromise?) -> Void) { + self.account = account + self.replaceRootController = replaceRootController + + super.init() + + self.navigationBar.backgroundColor = UIColor(white: 0.0, alpha: 0.6) + self.navigationBar.stripeColor = UIColor.clear + self.navigationBar.foregroundColor = UIColor.white + self.navigationBar.accentColor = UIColor.white + + self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Done", style: .done, target: self, action: #selector(self.donePressed)) + + self.statusBar.statusBarStyle = .White + + var initialEntries: [AvatarGalleryEntry] = [] + if let user = peer as? TelegramUser, !user.photo.isEmpty { + initialEntries.append(.topImage(user.photo)) + } else if let group = peer as? TelegramGroup { + initialEntries.append(.topImage(group.photo)) + } else if let channel = peer as? TelegramChannel { + initialEntries.append(.topImage(channel.photo)) + } + + let remoteEntriesSignal: Signal<[AvatarGalleryEntry], NoError> = requestPeerPhotos(account: account, peerId: peer.id) |> map { photos -> [AvatarGalleryEntry] in + var result: [AvatarGalleryEntry] = [] + if photos.isEmpty { + result = initialEntries + } else { + for photo in photos { + if result.isEmpty, let first = initialEntries.first { + let image = TelegramMediaImage(imageId: photo.image.imageId, representations: first.representations) + result.append(.image(image)) + } else { + result.append(.image(photo.image)) + } + } + } + return result + } + + let entriesSignal: Signal<[AvatarGalleryEntry], NoError> = .single(initialEntries) |> then(remoteEntriesSignal) + + self.disposable.set((entriesSignal |> deliverOnMainQueue).start(next: { [weak self] entries in + if let strongSelf = self { + strongSelf.entries = entries + strongSelf.centralEntryIndex = 0 + if strongSelf.isViewLoaded { + strongSelf.galleryNode.pager.replaceItems(strongSelf.entries.map({ PeerAvatarImageGalleryItem(account: account, entry: $0) }), centralItemIndex: 0, keepFirst: true) + + let ready = strongSelf.galleryNode.pager.ready() |> timeout(2.0, queue: Queue.mainQueue(), alternate: .single(Void())) |> afterNext { [weak strongSelf] _ in + strongSelf?.didSetReady = true + } + strongSelf._ready.set(ready |> map { true }) + } + } + })) + + self.centralItemAttributesDisposable.add(self.centralItemTitle.get().start(next: { [weak self] title in + self?.navigationItem.title = title + })) + + self.centralItemAttributesDisposable.add(self.centralItemTitleView.get().start(next: { [weak self] titleView in + self?.navigationItem.titleView = titleView + })) + + self.centralItemAttributesDisposable.add(self.centralItemFooterContentNode.get().start(next: { [weak self] footerContentNode in + self?.galleryNode.updatePresentationState({ + $0.withUpdatedFooterContentNode(footerContentNode) + }, transition: .immediate) + })) + + self.centralItemAttributesDisposable.add(self.centralItemNavigationStyle.get().start(next: { [weak self] style in + if let strongSelf = self { + switch style { + case .dark: + strongSelf.statusBar.statusBarStyle = .White + strongSelf.navigationBar.backgroundColor = UIColor(white: 0.0, alpha: 0.5) + strongSelf.navigationBar.stripeColor = UIColor.clear + strongSelf.navigationBar.foregroundColor = UIColor.white + strongSelf.navigationBar.accentColor = UIColor.white + strongSelf.galleryNode.backgroundNode.backgroundColor = UIColor.black + strongSelf.galleryNode.isBackgroundExtendedOverNavigationBar = true + case .light: + strongSelf.statusBar.statusBarStyle = .Black + strongSelf.navigationBar.backgroundColor = UIColor(red: 0.968626451, green: 0.968626451, blue: 0.968626451, alpha: 1.0) + strongSelf.navigationBar.foregroundColor = UIColor.black + strongSelf.navigationBar.accentColor = UIColor(0x007ee5) + strongSelf.navigationBar.stripeColor = UIColor(red: 0.6953125, green: 0.6953125, blue: 0.6953125, alpha: 1.0) + strongSelf.galleryNode.backgroundNode.backgroundColor = UIColor(0xbdbdc2) + strongSelf.galleryNode.isBackgroundExtendedOverNavigationBar = false + } + } + })) + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.disposable.dispose() + self.centralItemAttributesDisposable.dispose() + } + + @objc func donePressed() { + self.dismiss(forceAway: false) + } + + private func dismiss(forceAway: Bool) { + var animatedOutNode = true + var animatedOutInterface = false + + let completion = { [weak self] in + if animatedOutNode && animatedOutInterface { + self?._hiddenMedia.set(.single(nil)) + self?.presentingViewController?.dismiss(animated: false, completion: nil) + } + } + + if let centralItemNode = self.galleryNode.pager.centralItemNode(), let presentationArguments = self.presentationArguments as? AvatarGalleryControllerPresentationArguments { + if !self.entries.isEmpty { + if centralItemNode.index == 0, let transitionArguments = presentationArguments.transitionArguments(self.entries[centralItemNode.index]), !forceAway { + animatedOutNode = false + centralItemNode.animateOut(to: transitionArguments.transitionNode, completion: { + animatedOutNode = true + completion() + }) + } + } + } + + self.galleryNode.animateOut(animateContent: animatedOutNode, completion: { + animatedOutInterface = true + completion() + }) + } + + override func loadDisplayNode() { + let controllerInteraction = GalleryControllerInteraction(presentController: { [weak self] controller, arguments in + if let strongSelf = self { + strongSelf.present(controller, in: .window, with: arguments) + } + }, dismissController: { [weak self] in + self?.dismiss(forceAway: true) + }, replaceRootController: { [weak self] controller, ready in + if let strongSelf = self { + strongSelf.replaceRootController(controller, ready) + } + }) + self.displayNode = GalleryControllerNode(controllerInteraction: controllerInteraction) + self.displayNodeDidLoad() + + self.galleryNode.statusBar = self.statusBar + self.galleryNode.navigationBar = self.navigationBar + + self.galleryNode.transitionNodeForCentralItem = { [weak self] in + if let strongSelf = self { + if let centralItemNode = strongSelf.galleryNode.pager.centralItemNode(), let presentationArguments = strongSelf.presentationArguments as? AvatarGalleryControllerPresentationArguments { + if let transitionArguments = presentationArguments.transitionArguments(strongSelf.entries[centralItemNode.index]) { + return transitionArguments.transitionNode + } + } + } + return nil + } + self.galleryNode.dismiss = { [weak self] in + self?._hiddenMedia.set(.single(nil)) + self?.presentingViewController?.dismiss(animated: false, completion: nil) + } + + self.galleryNode.pager.replaceItems(self.entries.map({ PeerAvatarImageGalleryItem(account: self.account, entry: $0) }), centralItemIndex: self.centralEntryIndex) + + self.galleryNode.pager.centralItemIndexUpdated = { [weak self] index in + if let strongSelf = self { + var hiddenItem: AvatarGalleryEntry? + if let index = index { + hiddenItem = strongSelf.entries[index] + + if let node = strongSelf.galleryNode.pager.centralItemNode() { + strongSelf.centralItemTitle.set(node.title()) + strongSelf.centralItemTitleView.set(node.titleView()) + strongSelf.centralItemNavigationStyle.set(node.navigationStyle()) + strongSelf.centralItemFooterContentNode.set(node.footerContent()) + } + } + if strongSelf.didSetReady { + strongSelf._hiddenMedia.set(.single(hiddenItem)) + } + } + } + + let ready = self.galleryNode.pager.ready() |> timeout(2.0, queue: Queue.mainQueue(), alternate: .single(Void())) |> afterNext { [weak self] _ in + self?.didSetReady = true + } + self._ready.set(ready |> map { true }) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + var nodeAnimatesItself = false + + if let centralItemNode = self.galleryNode.pager.centralItemNode(), let presentationArguments = self.presentationArguments as? AvatarGalleryControllerPresentationArguments { + self.centralItemTitle.set(centralItemNode.title()) + self.centralItemTitleView.set(centralItemNode.titleView()) + self.centralItemNavigationStyle.set(centralItemNode.navigationStyle()) + self.centralItemFooterContentNode.set(centralItemNode.footerContent()) + + if let transitionArguments = presentationArguments.transitionArguments(self.entries[centralItemNode.index]) { + nodeAnimatesItself = true + centralItemNode.animateIn(from: transitionArguments.transitionNode) + + self._hiddenMedia.set(.single(self.entries[centralItemNode.index])) + } + } + + self.galleryNode.animateIn(animateContent: !nodeAnimatesItself) + } + + override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.galleryNode.frame = CGRect(origin: CGPoint(), size: layout.size) + self.galleryNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationHeight, transition: transition) + } +} diff --git a/TelegramUI/AvatarNode.swift b/TelegramUI/AvatarNode.swift index 90481cc1d4..57f460fd25 100644 --- a/TelegramUI/AvatarNode.swift +++ b/TelegramUI/AvatarNode.swift @@ -108,8 +108,14 @@ public final class AvatarNode: ASDisplayNode { } } - public func setPeer(account: Account, peer: Peer) { - let updatedState = AvatarNodeState.PeerAvatar(peer.id, peer.displayLetters, peer.smallProfileImage) + public func setPeer(account: Account, peer: Peer, temporaryRepresentation: TelegramMediaImageRepresentation? = nil) { + var representation: TelegramMediaImageRepresentation? + if let temporaryRepresentation = temporaryRepresentation { + representation = temporaryRepresentation + } else { + representation = peer.smallProfileImage + } + let updatedState = AvatarNodeState.PeerAvatar(peer.id, peer.displayLetters, representation) if updatedState != self.state { self.state = updatedState @@ -118,7 +124,7 @@ public final class AvatarNode: ASDisplayNode { self.displaySuspended = true self.contents = nil - if let signal = peerAvatarImage(account: account, peer: peer) { + if let signal = peerAvatarImage(account: account, peer: peer, temporaryRepresentation: temporaryRepresentation) { self.imageReady.set(self.imageNode.ready) self.imageNode.setSignal(signal) } else { diff --git a/TelegramUI/BlockedPeersController.swift b/TelegramUI/BlockedPeersController.swift index 9d1432b68f..3b906fa041 100644 --- a/TelegramUI/BlockedPeersController.swift +++ b/TelegramUI/BlockedPeersController.swift @@ -96,7 +96,7 @@ private enum BlockedPeersEntry: ItemListNodeEntry { func item(_ arguments: BlockedPeersControllerArguments) -> 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 + return ItemListPeerItem(account: arguments.account, peer: peer, presence: nil, text: .none, label: .none, 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) @@ -261,7 +261,7 @@ public func blockedPeersController(account: Account) -> ViewController { let previous = previousPeers previousPeers = peers - let controllerState = ItemListControllerState(title: "Blocked Users", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) + let controllerState = ItemListControllerState(title: .text("Blocked Users"), leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) let listState = ItemListNodeState(entries: blockedPeersControllerEntries(state: state, peers: peers), style: .blocks, emptyStateItem: emptyStateItem, animateChanges: previous != nil && peers != nil && previous!.count >= peers!.count) return (controllerState, (listState, arguments)) diff --git a/TelegramUI/ChangePhoneNumberCodeController.swift b/TelegramUI/ChangePhoneNumberCodeController.swift index 89d253f1ad..bc5d59e296 100644 --- a/TelegramUI/ChangePhoneNumberCodeController.swift +++ b/TelegramUI/ChangePhoneNumberCodeController.swift @@ -275,7 +275,7 @@ func changePhoneNumberCodeController(account: Account, phoneNumber: String, code }) } - let controllerState = ItemListControllerState(title: formatPhoneNumber(phoneNumber), leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: false) + let controllerState = ItemListControllerState(title: .text(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)) diff --git a/TelegramUI/ChannelAdminsController.swift b/TelegramUI/ChannelAdminsController.swift index cde63ca7d1..077d377b2c 100644 --- a/TelegramUI/ChannelAdminsController.swift +++ b/TelegramUI/ChannelAdminsController.swift @@ -211,7 +211,7 @@ private enum ChannelAdminsEntry: ItemListNodeEntry { default: peerText = "Moderator" } - return ItemListPeerItem(account: arguments.account, peer: participant.peer, presence: nil, text: .text(peerText), label: nil, editing: editing, switchValue: nil, enabled: enabled, sectionId: self.section, action: nil, setPeerIdWithRevealedOptions: { previousId, id in + return ItemListPeerItem(account: arguments.account, peer: participant.peer, presence: nil, text: .text(peerText), label: .none, editing: editing, switchValue: nil, enabled: enabled, sectionId: self.section, action: nil, setPeerIdWithRevealedOptions: { previousId, id in arguments.setPeerIdWithRevealedOptions(previousId, id) }, removePeer: { peerId in arguments.removeAdmin(peerId) @@ -652,7 +652,7 @@ public func channelAdminsController(account: Account, peerId: PeerId) -> ViewCon let previous = previousPeers previousPeers = admins - let controllerState = ItemListControllerState(title: "Admins", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) + let controllerState = ItemListControllerState(title: .text("Admins"), leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) let listState = ItemListNodeState(entries: ChannelAdminsControllerEntries(view: view, state: state, participants: admins), style: .blocks, animateChanges: previous != nil && admins != nil && previous!.count >= admins!.count) return (controllerState, (listState, arguments)) diff --git a/TelegramUI/ChannelBlacklistController.swift b/TelegramUI/ChannelBlacklistController.swift index 4b36279882..b56b11b4d2 100644 --- a/TelegramUI/ChannelBlacklistController.swift +++ b/TelegramUI/ChannelBlacklistController.swift @@ -96,7 +96,7 @@ private enum ChannelBlacklistEntry: ItemListNodeEntry { func item(_ arguments: ChannelBlacklistControllerArguments) -> ListViewItem { switch self { 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 + return ItemListPeerItem(account: arguments.account, peer: participant.peer, presence: nil, text: .none, label: .none, 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) @@ -300,7 +300,7 @@ public func channelBlacklistController(account: Account, peerId: PeerId) -> View let previous = previousPeers previousPeers = peers - let controllerState = ItemListControllerState(title: "Blacklist", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) + let controllerState = ItemListControllerState(title: .text("Blacklist"), leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) let listState = ItemListNodeState(entries: channelBlacklistControllerEntries(view: view, state: state, participants: peers), style: .blocks, emptyStateItem: emptyStateItem, animateChanges: previous != nil && peers != nil && previous!.count >= peers!.count) return (controllerState, (listState, arguments)) diff --git a/TelegramUI/ChannelInfoController.swift b/TelegramUI/ChannelInfoController.swift index 7f38c0973f..caffd0a331 100644 --- a/TelegramUI/ChannelInfoController.swift +++ b/TelegramUI/ChannelInfoController.swift @@ -3,9 +3,13 @@ import Display import SwiftSignalKit import Postbox import TelegramCore +import TelegramLegacyComponents private final class ChannelInfoControllerArguments { let account: Account + let avatarAndNameInfoContext: ItemListAvatarAndNameInfoItemContext + let tapAvatarAction: () -> Void + let changeProfilePhoto: () -> Void let updateEditingName: (ItemListAvatarAndNameInfoItemName) -> Void let updateEditingDescriptionText: (String) -> Void let openChannelTypeSetup: () -> Void @@ -19,8 +23,11 @@ private final class ChannelInfoControllerArguments { let deleteChannel: () -> Void let displayAddressNameContextMenu: (String) -> Void - init(account: Account, updateEditingName: @escaping (ItemListAvatarAndNameInfoItemName) -> Void, updateEditingDescriptionText: @escaping (String) -> Void, openChannelTypeSetup: @escaping () -> Void, changeNotificationMuteSettings: @escaping () -> Void, openSharedMedia: @escaping () -> Void, openAdmins: @escaping () -> Void, openMembers: @escaping () -> Void, openBanned: @escaping () -> Void, reportChannel: @escaping () -> Void, leaveChannel: @escaping () -> Void, deleteChannel: @escaping () -> Void, displayAddressNameContextMenu: @escaping (String) -> Void) { + init(account: Account, avatarAndNameInfoContext: ItemListAvatarAndNameInfoItemContext, tapAvatarAction: @escaping () -> Void, changeProfilePhoto: @escaping () -> Void, updateEditingName: @escaping (ItemListAvatarAndNameInfoItemName) -> Void, updateEditingDescriptionText: @escaping (String) -> Void, openChannelTypeSetup: @escaping () -> Void, changeNotificationMuteSettings: @escaping () -> Void, openSharedMedia: @escaping () -> Void, openAdmins: @escaping () -> Void, openMembers: @escaping () -> Void, openBanned: @escaping () -> Void, reportChannel: @escaping () -> Void, leaveChannel: @escaping () -> Void, deleteChannel: @escaping () -> Void, displayAddressNameContextMenu: @escaping (String) -> Void) { self.account = account + self.avatarAndNameInfoContext = avatarAndNameInfoContext + self.tapAvatarAction = tapAvatarAction + self.changeProfilePhoto = changeProfilePhoto self.updateEditingName = updateEditingName self.updateEditingDescriptionText = updateEditingDescriptionText self.openChannelTypeSetup = openChannelTypeSetup @@ -48,9 +55,10 @@ private enum ChannelInfoEntryTag { } private enum ChannelInfoEntry: ItemListNodeEntry { - case info(peer: Peer?, cachedData: CachedPeerData?, state: ItemListAvatarAndNameInfoItemState) + case info(peer: Peer?, cachedData: CachedPeerData?, state: ItemListAvatarAndNameInfoItemState, updatingAvatar: TelegramMediaImageRepresentation?) case about(text: String) case addressName(value: String) + case channelPhotoSetup case channelTypeSetup(isPublic: Bool) case channelDescriptionSetup(text: String) case admins(count: Int32) @@ -64,7 +72,7 @@ private enum ChannelInfoEntry: ItemListNodeEntry { var section: ItemListSectionId { switch self { - case .info, .about, .addressName, .channelTypeSetup, .channelDescriptionSetup: + case .info, .about, .addressName, .channelPhotoSetup, .channelTypeSetup, .channelDescriptionSetup: return ChannelInfoSection.info.rawValue case .admins, .members, .banned: return ChannelInfoSection.members.rawValue @@ -83,33 +91,35 @@ private enum ChannelInfoEntry: ItemListNodeEntry { return 1 case .addressName: return 2 - case .channelDescriptionSetup: + case .channelPhotoSetup: return 3 - case .channelTypeSetup: + case .channelDescriptionSetup: return 4 - case .admins: + case .channelTypeSetup: return 5 - case .members: + case .admins: return 6 - case .banned: + case .members: return 7 - case .notifications: + case .banned: return 8 - case .sharedMedia: + case .notifications: return 9 - case .report: + case .sharedMedia: return 10 - case .leave: + case .report: return 11 - case .deleteChannel: + case .leave: return 12 + case .deleteChannel: + return 13 } } static func ==(lhs: ChannelInfoEntry, rhs: ChannelInfoEntry) -> Bool { switch lhs { - case let .info(lhsPeer, lhsCachedData, lhsState): - if case let .info(rhsPeer, rhsCachedData, rhsState) = rhs { + case let .info(lhsPeer, lhsCachedData, lhsState, lhsUpdatingAvatar): + if case let .info(rhsPeer, rhsCachedData, rhsState, rhsUpdatingAvatar) = rhs { if let lhsPeer = lhsPeer, let rhsPeer = rhsPeer { if !lhsPeer.isEqual(rhsPeer) { return false @@ -127,6 +137,9 @@ private enum ChannelInfoEntry: ItemListNodeEntry { if lhsState != rhsState { return false } + if lhsUpdatingAvatar != rhsUpdatingAvatar { + return false + } return true } else { return false @@ -143,6 +156,12 @@ private enum ChannelInfoEntry: ItemListNodeEntry { } else { return false } + case .channelPhotoSetup: + if case .channelPhotoSetup = rhs { + return true + } else { + return false + } case let .channelTypeSetup(isPublic): if case .channelTypeSetup(isPublic) = rhs { return true @@ -190,16 +209,22 @@ private enum ChannelInfoEntry: ItemListNodeEntry { func item(_ arguments: ChannelInfoControllerArguments) -> ListViewItem { switch self { - case let .info(peer, cachedData, state): + case let .info(peer, cachedData, state, updatingAvatar): return ItemListAvatarAndNameInfoItem(account: arguments.account, peer: peer, presence: nil, cachedData: cachedData, state: state, sectionId: self.section, style: .plain, editingNameUpdated: { editingName in arguments.updateEditingName(editingName) - }) + }, avatarTapped: { + arguments.tapAvatarAction() + }, context: arguments.avatarAndNameInfoContext, updatingImage: updatingAvatar) case let .about(text): return ItemListTextWithLabelItem(label: "about", text: text, multiline: true, sectionId: self.section, action: nil) case let .addressName(value): return ItemListTextWithLabelItem(label: "share link", text: "https://t.me/\(value)", multiline: false, sectionId: self.section, action: { arguments.displayAddressNameContextMenu("https://t.me/\(value)") }, tag: ChannelInfoEntryTag.addressName) + case .channelPhotoSetup: + return ItemListActionItem(title: "Set Channel Photo", kind: .generic, alignment: .natural, sectionId: self.section, style: .plain, action: { + arguments.changeProfilePhoto() + }) case let .channelTypeSetup(isPublic): return ItemListDisclosureItem(title: "Channel Type", label: isPublic ? "Public" : "Private", sectionId: self.section, style: .plain, action: { arguments.openChannelTypeSetup() @@ -253,20 +278,26 @@ private enum ChannelInfoEntry: ItemListNodeEntry { } private struct ChannelInfoState: Equatable { + let updatingAvatar: TelegramMediaImageRepresentation? let editingState: ChannelInfoEditingState? let savingData: Bool - init(editingState: ChannelInfoEditingState?, savingData: Bool) { + init(updatingAvatar: TelegramMediaImageRepresentation?, editingState: ChannelInfoEditingState?, savingData: Bool) { + self.updatingAvatar = updatingAvatar self.editingState = editingState self.savingData = savingData } init() { + self.updatingAvatar = nil self.editingState = nil self.savingData = false } static func ==(lhs: ChannelInfoState, rhs: ChannelInfoState) -> Bool { + if lhs.updatingAvatar != rhs.updatingAvatar { + return false + } if lhs.editingState != rhs.editingState { return false } @@ -276,12 +307,16 @@ private struct ChannelInfoState: Equatable { return true } + func withUpdatedUpdatingAvatar(_ updatingAvatar: TelegramMediaImageRepresentation?) -> ChannelInfoState { + return ChannelInfoState(updatingAvatar: updatingAvatar, editingState: self.editingState, savingData: self.savingData) + } + func withUpdatedEditingState(_ editingState: ChannelInfoEditingState?) -> ChannelInfoState { - return ChannelInfoState(editingState: editingState, savingData: self.savingData) + return ChannelInfoState(updatingAvatar: self.updatingAvatar, editingState: editingState, savingData: self.savingData) } func withUpdatedSavingData(_ savingData: Bool) -> ChannelInfoState { - return ChannelInfoState(editingState: self.editingState, savingData: savingData) + return ChannelInfoState(updatingAvatar: self.updatingAvatar, editingState: self.editingState, savingData: savingData) } } @@ -322,7 +357,11 @@ private func channelInfoEntries(account: Account, view: PeerView, state: Channel } let infoState = ItemListAvatarAndNameInfoItemState(editingName: canManageChannel ? state.editingState?.editingName : nil, updatingName: nil) - entries.append(.info(peer: peer, cachedData: view.cachedData, state: infoState)) + entries.append(.info(peer: peer, cachedData: view.cachedData, state: infoState, updatingAvatar: state.updatingAvatar)) + + if state.editingState != nil && canManageChannel { + entries.append(.channelPhotoSetup) + } if let cachedChannelData = view.cachedData as? CachedChannelData { if let editingState = state.editingState, canManageChannel { @@ -408,7 +447,7 @@ public func channelInfoController(account: Account, peerId: PeerId) -> ViewContr } var pushControllerImpl: ((ViewController) -> Void)? - var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments) -> Void)? + var presentControllerImpl: ((ViewController, Any?) -> Void)? var popToRootControllerImpl: (() -> Void)? var displayAddressNameContextMenuImpl: ((String) -> Void)? @@ -427,7 +466,78 @@ public func channelInfoController(account: Account, peerId: PeerId) -> ViewContr let changeMuteSettingsDisposable = MetaDisposable() actionsDisposable.add(changeMuteSettingsDisposable) - let arguments = ChannelInfoControllerArguments(account: account, updateEditingName: { editingName in + let hiddenAvatarRepresentationDisposable = MetaDisposable() + actionsDisposable.add(hiddenAvatarRepresentationDisposable) + + let updateAvatarDisposable = MetaDisposable() + actionsDisposable.add(updateAvatarDisposable) + let currentAvatarMixin = Atomic(value: nil) + + var avatarGalleryTransitionArguments: ((AvatarGalleryEntry) -> GalleryTransitionArguments?)? + let avatarAndNameInfoContext = ItemListAvatarAndNameInfoItemContext() + var updateHiddenAvatarImpl: (() -> Void)? + + let arguments = ChannelInfoControllerArguments(account: account, avatarAndNameInfoContext: avatarAndNameInfoContext, tapAvatarAction: { + let _ = (account.postbox.loadedPeerWithId(peerId) |> take(1) |> deliverOnMainQueue).start(next: { peer in + if peer.profileImageRepresentations.isEmpty { + return + } + + let galleryController = AvatarGalleryController(account: account, peer: peer, replaceRootController: { controller, ready in + + }) + hiddenAvatarRepresentationDisposable.set((galleryController.hiddenMedia |> deliverOnMainQueue).start(next: { entry in + avatarAndNameInfoContext.hiddenAvatarRepresentation = entry?.representations.first + updateHiddenAvatarImpl?() + })) + presentControllerImpl?(galleryController, AvatarGalleryControllerPresentationArguments(transitionArguments: { entry in + return avatarGalleryTransitionArguments?(entry) + })) + }) + }, changeProfilePhoto: { + let emptyController = LegacyEmptyController() + let navigationController = makeLegacyNavigationController(rootController: emptyController) + navigationController.setNavigationBarHidden(true, animated: false) + navigationController.navigationBar.transform = CGAffineTransform(translationX: -1000.0, y: 0.0) + + let legacyController = LegacyController(legacyController: navigationController, presentation: .custom) + + presentControllerImpl?(legacyController, nil) + + let mixin = TGMediaAvatarMenuMixin(parentController: emptyController, hasDeleteButton: false, personalPhoto: true)! + mixin.applicationInterface = legacyController.applicationInterface + let _ = currentAvatarMixin.swap(mixin) + mixin.didDismiss = { [weak legacyController] in + legacyController?.dismiss() + } + mixin.didFinishWithImage = { image in + if let image = image { + if let data = UIImageJPEGRepresentation(image, 0.6) { + let resource = LocalFileMediaResource(fileId: arc4random64()) + account.postbox.mediaBox.storeResourceData(resource.id, data: data) + let representation = TelegramMediaImageRepresentation(dimensions: CGSize(width: 640.0, height: 640.0), resource: resource) + updateState { + $0.withUpdatedUpdatingAvatar(representation) + } + updateAvatarDisposable.set((updatePeerPhoto(account: account, peerId: peerId, resource: resource) |> deliverOnMainQueue).start(next: { result in + switch result { + case .complete: + updateState { + $0.withUpdatedUpdatingAvatar(nil) + } + case .progress: + break + } + })) + } + } + } + mixin.didDismiss = { [weak legacyController] in + let _ = currentAvatarMixin.swap(nil) + legacyController?.dismiss() + } + mixin.present() + }, updateEditingName: { editingName in updateState { state in if let editingState = state.editingState { return state.withUpdatedEditingState(ChannelInfoEditingState(editingName: editingName, editingDescriptionText: editingState.editingDescriptionText)) @@ -612,7 +722,7 @@ public func channelInfoController(account: Account, peerId: PeerId) -> ViewContr }) } - let controllerState = ItemListControllerState(title: "Info", leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton) + let controllerState = ItemListControllerState(title: .text("Info"), leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton) let listState = ItemListNodeState(entries: channelInfoEntries(account: account, view: view, state: state), style: .plain) return (controllerState, (listState, arguments)) @@ -660,5 +770,28 @@ public func channelInfoController(account: Account, peerId: PeerId) -> ViewContr } } } + avatarGalleryTransitionArguments = { [weak controller] entry in + if let controller = controller { + var result: (ASDisplayNode, CGRect)? + controller.forEachItemNode { itemNode in + if let itemNode = itemNode as? ItemListAvatarAndNameInfoItemNode { + result = itemNode.avatarTransitionNode() + } + } + if let (node, _) = result { + return GalleryTransitionArguments(transitionNode: node, transitionContainerNode: controller.displayNode, transitionBackgroundNode: controller.displayNode) + } + } + return nil + } + updateHiddenAvatarImpl = { [weak controller] in + if let controller = controller { + controller.forEachItemNode { itemNode in + if let itemNode = itemNode as? ItemListAvatarAndNameInfoItemNode { + itemNode.updateAvatarHidden() + } + } + } + } return controller } diff --git a/TelegramUI/ChannelMembersController.swift b/TelegramUI/ChannelMembersController.swift index 55e04e1f92..f234af93d0 100644 --- a/TelegramUI/ChannelMembersController.swift +++ b/TelegramUI/ChannelMembersController.swift @@ -135,7 +135,7 @@ private enum ChannelMembersEntry: ItemListNodeEntry { case .addMemberInfo: 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 + return ItemListPeerItem(account: arguments.account, peer: participant.peer, presence: nil, text: .none, label: .none, 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) @@ -417,7 +417,7 @@ public func channelMembersController(account: Account, peerId: PeerId) -> ViewCo let previous = previousPeers previousPeers = peers - let controllerState = ItemListControllerState(title: "Members", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) + let controllerState = ItemListControllerState(title: .text("Members"), leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) let listState = ItemListNodeState(entries: ChannelMembersControllerEntries(account: account, view: view, state: state, participants: peers), style: .blocks, emptyStateItem: emptyStateItem, animateChanges: previous != nil && peers != nil && previous!.count >= peers!.count) return (controllerState, (listState, arguments)) diff --git a/TelegramUI/ChannelVisibilityController.swift b/TelegramUI/ChannelVisibilityController.swift index bb01202987..9e479cf150 100644 --- a/TelegramUI/ChannelVisibilityController.swift +++ b/TelegramUI/ChannelVisibilityController.swift @@ -257,7 +257,7 @@ private enum ChannelVisibilityEntry: ItemListNodeEntry { if let addressName = peer.addressName { label = "t.me/" + addressName } - return ItemListPeerItem(account: arguments.account, peer: peer, presence: nil, text: .text(label), label: nil, editing: editing, switchValue: nil, enabled: enabled, sectionId: self.section, action: nil, setPeerIdWithRevealedOptions: { previousId, id in + return ItemListPeerItem(account: arguments.account, peer: peer, presence: nil, text: .text(label), label: .none, editing: editing, switchValue: nil, enabled: enabled, sectionId: self.section, action: nil, setPeerIdWithRevealedOptions: { previousId, id in arguments.setPeerIdWithRevealedOptions(previousId, id) }, removePeer: { peerId in arguments.revokePeerId(peerId) @@ -666,7 +666,7 @@ public func channelVisibilityController(account: Account, peerId: PeerId, mode: }) } - let controllerState = ItemListControllerState(title: isGroup ? "Group Type" : "Channel Link", leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, animateChanges: false) + let controllerState = ItemListControllerState(title: .text(isGroup ? "Group Type" : "Channel Link"), leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, animateChanges: false) let listState = ItemListNodeState(entries: channelVisibilityControllerEntries(view: view, publicChannelsToRevoke: publicChannelsToRevoke, state: state), style: .blocks, animateChanges: false) return (controllerState, (listState, arguments)) @@ -709,7 +709,6 @@ public func channelVisibilityController(account: Account, peerId: PeerId, mode: return nil } })) - } } } diff --git a/TelegramUI/ChatButtonKeyboardInputNode.swift b/TelegramUI/ChatButtonKeyboardInputNode.swift index 6a9ce825e4..a47e27a062 100644 --- a/TelegramUI/ChatButtonKeyboardInputNode.swift +++ b/TelegramUI/ChatButtonKeyboardInputNode.swift @@ -183,6 +183,8 @@ final class ChatButtonKeyboardInputNode: ChatInputNode { controllerInteraction.openPeer(peerId, .chat(textInputState: ChatTextInputState(inputText: "@\(addressName) \(query)")), nil) } } + case .payment: + break } } } diff --git a/TelegramUI/ChatController.swift b/TelegramUI/ChatController.swift index 32267fe7eb..2a8bc667ab 100644 --- a/TelegramUI/ChatController.swift +++ b/TelegramUI/ChatController.swift @@ -108,7 +108,9 @@ public class ChatController: TelegramController { if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(id) { for media in message.media { if let file = media as? TelegramMediaFile { - galleryMedia = file + if !file.isAnimated { + galleryMedia = file + } } else if let image = media as? TelegramMediaImage { galleryMedia = image } else if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content { @@ -126,7 +128,11 @@ public class ChatController: TelegramController { 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) + let controller = StickerPackPreviewController(account: strongSelf.account, stickerPack: reference) + controller.sendSticker = { file in + self?.controllerInteraction?.sendSticker(file) + } + strongSelf.present(controller, in: .window) } break } @@ -138,7 +144,11 @@ public class ChatController: TelegramController { player.control(.navigation(.next)) } } else { - let gallery = GalleryController(account: strongSelf.account, messageId: id) + let gallery = GalleryController(account: strongSelf.account, messageId: id, replaceRootController: { controller, ready in + if let strongSelf = self { + (strongSelf.navigationController as? NavigationController)?.replaceTopController(controller, animated: false, ready: ready) + } + }) strongSelf.galleryHiddenMesageAndMediaDisposable.set(gallery.hiddenMedia.start(next: { [weak strongSelf] messageIdAndMedia in if let strongSelf = strongSelf { @@ -254,7 +264,7 @@ public class ChatController: TelegramController { }, toggleMessageSelection: { [weak self] id in if let strongSelf = self, strongSelf.isNodeLoaded { if let _ = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(id) { - strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: true, { $0.updatedInterfaceState { $0.withToggledSelectedMessage(id) } }) + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withToggledSelectedMessage(id) } }) } } }, sendMessage: { [weak self] text in @@ -284,6 +294,17 @@ public class ChatController: TelegramController { }) let _ = enqueueMessages(account: strongSelf.account, peerId: strongSelf.peerId, messages: [.message(text: "", attributes: [], media: file, replyToMessageId: strongSelf.presentationInterfaceState.interfaceState.replyMessageId)]).start() } + }, sendGif: { [weak self] file in + if let strongSelf = self { + strongSelf.chatDisplayNode.setupSendActionOnViewUpdate({ + if let strongSelf = self { + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: false, { + $0.updatedInterfaceState { $0.withUpdatedReplyMessageId(nil) } + }) + } + }) + let _ = enqueueMessages(account: strongSelf.account, peerId: strongSelf.peerId, messages: [.message(text: "", attributes: [], media: file, replyToMessageId: strongSelf.presentationInterfaceState.interfaceState.replyMessageId)]).start() + } }, requestMessageActionCallback: { [weak self] messageId, data, isGame in if let strongSelf = self { strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { @@ -402,6 +423,37 @@ public class ChatController: TelegramController { } }) } + }, openMessageShareMenu: { [weak self] id in + if let strongSelf = self { + var copyLink: (() -> Void)? + var shareAction: (([PeerId]) -> Void)? + let shareController = ShareController(account: strongSelf.account, shareAction: { peerIds in + shareAction?(peerIds) + }, defaultAction: ShareControllerAction(title: "Copy Link", action: { + copyLink?() + })) + strongSelf.present(shareController, in: .window) + shareAction = { [weak shareController] peerIds in + shareController?.dismiss() + + if let strongSelf = self { + for peerId in peerIds { + let _ = enqueueMessages(account: strongSelf.account, peerId: peerId, messages: [.forward(source: id)]).start() + } + } + } + copyLink = { [weak shareController] in + shareController?.dismiss() + + if let strongSelf = self, let peer = strongSelf.presentationInterfaceState.peer { + if let addressName = peer.addressName { + UIPasteboard.general.string = "https://t.me/\(addressName)/\(id.id)" + } + } + } + } + }, presentController: { [weak self] controller, arguments in + self?.present(controller, in: .window, with: arguments) }) self.controllerInteraction = controllerInteraction @@ -748,6 +800,27 @@ public class ChatController: TelegramController { } } + self.chatDisplayNode.historyNode.scrolledToIndex = { [weak self] index in + if let strongSelf = self { + if let controllerInteraction = strongSelf.controllerInteraction { + if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(index.id) { + let highlightedState = ChatInterfaceHighlightedState(messageStableId: message.stableId) + controllerInteraction.highlightedState = highlightedState + strongSelf.updateItemNodesHighlightedStates(animated: true) + + strongSelf.messageContextDisposable.set((Signal.complete() |> delay(0.7, queue: Queue.mainQueue())).start(completed: { + if let strongSelf = self, let controllerInteraction = strongSelf.controllerInteraction { + if controllerInteraction.highlightedState == highlightedState { + controllerInteraction.highlightedState = nil + strongSelf.updateItemNodesHighlightedStates(animated: true) + } + } + })) + } + } + } + } + self.chatDisplayNode.historyNode.maxVisibleMessageIndexUpdated = { [weak self] index in if let strongSelf = self, !strongSelf.historyNavigationStack.isEmpty { strongSelf.historyNavigationStack.filterOutIndicesLessThan(index) @@ -792,7 +865,7 @@ public class ChatController: TelegramController { stationaryItemRange = (maxInsertedItem + 1, Int.max) } - mappedTransition = (ChatHistoryListViewTransition(historyView: transition.historyView, deleteItems: deleteItems, insertItems: insertItems, updateItems: transition.updateItems, options: options, scrollToItem: scrollToItem, stationaryItemRange: stationaryItemRange, initialData: transition.initialData, keyboardButtonsMessage: transition.keyboardButtonsMessage, cachedData: transition.cachedData), updateSizeAndInsets) + mappedTransition = (ChatHistoryListViewTransition(historyView: transition.historyView, deleteItems: deleteItems, insertItems: insertItems, updateItems: transition.updateItems, options: options, scrollToItem: scrollToItem, stationaryItemRange: stationaryItemRange, initialData: transition.initialData, keyboardButtonsMessage: transition.keyboardButtonsMessage, cachedData: transition.cachedData, scrolledToIndex: transition.scrolledToIndex), updateSizeAndInsets) }) if let mappedTransition = mappedTransition { @@ -1450,7 +1523,7 @@ public class ChatController: TelegramController { if let controllerInteraction = self.controllerInteraction { if updatedChatPresentationInterfaceState.interfaceState.selectionState != controllerInteraction.selectionState { - let animated = controllerInteraction.selectionState == nil || updatedChatPresentationInterfaceState.interfaceState.selectionState == nil + //let animated = controllerInteraction.selectionState == nil || updatedChatPresentationInterfaceState.interfaceState.selectionState == nil controllerInteraction.selectionState = updatedChatPresentationInterfaceState.interfaceState.selectionState self.updateItemNodesSelectionStates(animated: animated) } @@ -1526,7 +1599,7 @@ public class ChatController: TelegramController { let controller = generator({ controller in return presentOverlayController!(controller) }) - let legacyController = LegacyController(legacyController: controller, presentation: .modal) + let legacyController = LegacyController(legacyController: controller, presentation: .modal(animateIn: false)) presentOverlayController = { [weak legacyController] controller in if let legacyController = legacyController { diff --git a/TelegramUI/ChatControllerInteraction.swift b/TelegramUI/ChatControllerInteraction.swift index 59738f7018..3cff0798ad 100644 --- a/TelegramUI/ChatControllerInteraction.swift +++ b/TelegramUI/ChatControllerInteraction.swift @@ -2,6 +2,7 @@ import Foundation import Postbox import AsyncDisplayKit import TelegramCore +import Display public enum ChatControllerInitialBotStartBehavior { case interactive @@ -19,8 +20,12 @@ public enum ChatControllerInteractionNavigateToPeer { case withBotStartPayload(ChatControllerInitialBotStart) } -struct ChatInterfaceHighlightedState { +struct ChatInterfaceHighlightedState: Equatable { let messageStableId: UInt32 + + static func ==(lhs: ChatInterfaceHighlightedState, rhs: ChatInterfaceHighlightedState) -> Bool { + return lhs.messageStableId == rhs.messageStableId + } } public final class ChatControllerInteraction { @@ -38,6 +43,7 @@ public final class ChatControllerInteraction { let toggleMessageSelection: (MessageId) -> Void let sendMessage: (String) -> Void let sendSticker: (TelegramMediaFile) -> Void + let sendGif: (TelegramMediaFile) -> Void let requestMessageActionCallback: (MessageId, MemoryBuffer?, Bool) -> Void let openUrl: (String) -> Void let shareCurrentLocation: () -> Void @@ -46,8 +52,10 @@ public final class ChatControllerInteraction { let openInstantPage: (MessageId) -> Void let openHashtag: (String?, String) -> Void let updateInputState: ((ChatTextInputState) -> ChatTextInputState) -> Void + let openMessageShareMenu: (MessageId) -> Void + let presentController: (ViewController, Any?) -> 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) { + 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, sendGif: @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, openMessageShareMenu: @escaping (MessageId) -> Void, presentController: @escaping (ViewController, Any?) -> Void) { self.openMessage = openMessage self.openSecretMessagePreview = openSecretMessagePreview self.closeSecretMessagePreview = closeSecretMessagePreview @@ -59,6 +67,7 @@ public final class ChatControllerInteraction { self.toggleMessageSelection = toggleMessageSelection self.sendMessage = sendMessage self.sendSticker = sendSticker + self.sendGif = sendGif self.requestMessageActionCallback = requestMessageActionCallback self.openUrl = openUrl self.shareCurrentLocation = shareCurrentLocation @@ -67,5 +76,7 @@ public final class ChatControllerInteraction { self.openInstantPage = openInstantPage self.openHashtag = openHashtag self.updateInputState = updateInputState + self.openMessageShareMenu = openMessageShareMenu + self.presentController = presentController } } diff --git a/TelegramUI/ChatControllerNode.swift b/TelegramUI/ChatControllerNode.swift index dadbb1a479..667d0a1fc5 100644 --- a/TelegramUI/ChatControllerNode.swift +++ b/TelegramUI/ChatControllerNode.swift @@ -512,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.ulpOfOne), 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), size: CGSize(width: layout.size.width, height: max(insets.bottom, dismissedInputNode.bounds.size.height))), force: true, completion: { [weak self, weak dismissedInputNode] completed in if completed { if let strongSelf = self { if strongSelf.inputNode !== dismissedInputNode { diff --git a/TelegramUI/ChatDocumentGalleryItem.swift b/TelegramUI/ChatDocumentGalleryItem.swift index d8dc3dfe17..a012b72e0f 100644 --- a/TelegramUI/ChatDocumentGalleryItem.swift +++ b/TelegramUI/ChatDocumentGalleryItem.swift @@ -17,7 +17,7 @@ class ChatDocumentGalleryItem: GalleryItem { } func node() -> GalleryItemNode { - let node = ChatDocumentGalleryItemNode() + let node = ChatDocumentGalleryItemNode(account: self.account) for media in self.message.media { if let file = media as? TelegramMediaFile { @@ -34,6 +34,7 @@ class ChatDocumentGalleryItem: GalleryItem { if let location = self.location { node._title.set(.single("\(location.index + 1) of \(location.count)")) } + node.setMessage(self.message) return node } @@ -41,6 +42,7 @@ class ChatDocumentGalleryItem: GalleryItem { func updateNode(node: GalleryItemNode) { if let node = node as? ChatDocumentGalleryItemNode, let location = self.location { node._title.set(.single("\(location.index + 1) of \(location.count)")) + node.setMessage(self.message) } } } @@ -55,7 +57,11 @@ class ChatDocumentGalleryItemNode: GalleryItemNode { private var itemIsVisible = false - override init() { + private var message: Message? + + private let footerContentNode: ChatItemGalleryFooterContentNode + + init(account: Account) { if #available(iOS 9.0, *) { let webView = WKWebView() self.webView = webView @@ -64,6 +70,7 @@ class ChatDocumentGalleryItemNode: GalleryItemNode { webView.scalesPageToFit = true self.webView = webView } + self.footerContentNode = ChatItemGalleryFooterContentNode(account: account) super.init() @@ -80,6 +87,10 @@ class ChatDocumentGalleryItemNode: GalleryItemNode { self.webView.frame = CGRect(origin: CGPoint(x: 0.0, y: navigationBarHeight), size: CGSize(width: layout.size.width, height: layout.size.height - navigationBarHeight)) } + fileprivate func setMessage(_ message: Message) { + self.footerContentNode.setMessage(message) + } + override func navigationStyle() -> Signal { return .single(.light) } @@ -199,4 +210,8 @@ class ChatDocumentGalleryItemNode: GalleryItemNode { intermediateCompletion() }) } + + override func footerContent() -> Signal { + return .single(self.footerContentNode) + } } diff --git a/TelegramUI/ChatHistoryGridNode.swift b/TelegramUI/ChatHistoryGridNode.swift index 606fd96d94..2aa08fe469 100644 --- a/TelegramUI/ChatHistoryGridNode.swift +++ b/TelegramUI/ChatHistoryGridNode.swift @@ -372,7 +372,7 @@ public final class ChatHistoryGridNode: GridNode, ChatHistoryNode { var updateLayout: GridNodeUpdateLayout? if let updateSizeAndInsets = updateSizeAndInsets { - updateLayout = GridNodeUpdateLayout(layout: GridNodeLayout(size: updateSizeAndInsets.size, insets: updateSizeAndInsets.insets, preloadSize: 400.0, itemSize: CGSize(width: 200.0, height: 200.0)), transition: .immediate) + updateLayout = GridNodeUpdateLayout(layout: GridNodeLayout(size: updateSizeAndInsets.size, insets: updateSizeAndInsets.insets, preloadSize: 400.0, type: .fixed(itemSize: CGSize(width: 200.0, height: 200.0))), transition: .immediate) } self.transaction(GridNodeTransaction(deleteItems: mappedTransition.deleteItems, insertItems: mappedTransition.insertItems, updateItems: mappedTransition.updateItems, scrollToItem: mappedTransition.scrollToItem, updateLayout: updateLayout, stationaryItems: transition.stationaryItems, updateFirstIndexInSectionOffset: mappedTransition.topOffsetWithinMonth), completion: completion) @@ -383,7 +383,7 @@ public final class ChatHistoryGridNode: GridNode, ChatHistoryNode { } public func updateLayout(transition: ContainedViewLayoutTransition, updateSizeAndInsets: ListViewUpdateSizeAndInsets) { - self.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: updateSizeAndInsets.size, insets: updateSizeAndInsets.insets, preloadSize: 400.0, itemSize: itemSizeForContainerLayout(size: updateSizeAndInsets.size)), transition: .immediate), stationaryItems: .none,updateFirstIndexInSectionOffset: nil), completion: { _ in }) + self.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: updateSizeAndInsets.size, insets: updateSizeAndInsets.insets, preloadSize: 400.0, type: .fixed(itemSize: itemSizeForContainerLayout(size: updateSizeAndInsets.size))), transition: .immediate), stationaryItems: .none,updateFirstIndexInSectionOffset: nil), completion: { _ in }) if !self.dequeuedInitialTransitionOnLayout { self.dequeuedInitialTransitionOnLayout = true diff --git a/TelegramUI/ChatHistoryListNode.swift b/TelegramUI/ChatHistoryListNode.swift index 9e4bca9076..b001d10820 100644 --- a/TelegramUI/ChatHistoryListNode.swift +++ b/TelegramUI/ChatHistoryListNode.swift @@ -68,6 +68,7 @@ struct ChatHistoryViewTransition { let initialData: InitialMessageHistoryData? let keyboardButtonsMessage: Message? let cachedData: CachedPeerData? + let scrolledToIndex: MessageIndex? } struct ChatHistoryListViewTransition { @@ -81,6 +82,7 @@ struct ChatHistoryListViewTransition { let initialData: InitialMessageHistoryData? let keyboardButtonsMessage: Message? let cachedData: CachedPeerData? + let scrolledToIndex: MessageIndex? } private func maxMessageIndexForEntries(_ entries: [ChatHistoryEntry], indexRange: (Int, Int)) -> (incoming: MessageIndex?, overall: MessageIndex?) { @@ -161,7 +163,7 @@ private func mappedUpdateEntries(account: Account, peerId: PeerId, controllerInt } private func mappedChatHistoryViewListTransition(account: Account, peerId: PeerId, controllerInteraction: ChatControllerInteraction, mode: ChatHistoryListMode, transition: ChatHistoryViewTransition) -> ChatHistoryListViewTransition { - return ChatHistoryListViewTransition(historyView: transition.historyView, deleteItems: transition.deleteItems, insertItems: mappedInsertEntries(account: account, peerId: peerId, controllerInteraction: controllerInteraction, mode: mode, entries: transition.insertEntries), updateItems: mappedUpdateEntries(account: account, peerId: peerId, controllerInteraction: controllerInteraction, mode: mode, entries: transition.updateEntries), options: transition.options, scrollToItem: transition.scrollToItem, stationaryItemRange: transition.stationaryItemRange, initialData: transition.initialData, keyboardButtonsMessage: transition.keyboardButtonsMessage, cachedData: transition.cachedData) + return ChatHistoryListViewTransition(historyView: transition.historyView, deleteItems: transition.deleteItems, insertItems: mappedInsertEntries(account: account, peerId: peerId, controllerInteraction: controllerInteraction, mode: mode, entries: transition.insertEntries), updateItems: mappedUpdateEntries(account: account, peerId: peerId, controllerInteraction: controllerInteraction, mode: mode, entries: transition.updateEntries), options: transition.options, scrollToItem: transition.scrollToItem, stationaryItemRange: transition.stationaryItemRange, initialData: transition.initialData, keyboardButtonsMessage: transition.keyboardButtonsMessage, cachedData: transition.cachedData, scrolledToIndex: transition.scrolledToIndex) } private final class ChatHistoryTransactionOpaqueState { @@ -225,6 +227,8 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { private var maxVisibleMessageIndexReported: MessageIndex? var maxVisibleMessageIndexUpdated: ((MessageIndex) -> Void)? + var scrolledToIndex: ((MessageIndex) -> Void)? + public init(account: Account, peerId: PeerId, tagMask: MessageTags?, messageId: MessageId?, controllerInteraction: ChatControllerInteraction, mode: ChatHistoryListMode = .bubbles) { self.account = account self.peerId = peerId @@ -472,6 +476,11 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { } strongSelf.enqueuedHistoryViewTransition = (transition, { + if let scrolledToIndex = transition.scrolledToIndex { + if let strongSelf = self { + strongSelf.scrolledToIndex?(scrolledToIndex) + } + } subscriber.putCompletion() }) diff --git a/TelegramUI/ChatImageGalleryItem.swift b/TelegramUI/ChatImageGalleryItem.swift index 54518731b2..157fdaba91 100644 --- a/TelegramUI/ChatImageGalleryItem.swift +++ b/TelegramUI/ChatImageGalleryItem.swift @@ -17,18 +17,18 @@ class ChatImageGalleryItem: GalleryItem { } func node() -> GalleryItemNode { - let node = ChatImageGalleryItemNode() + let node = ChatImageGalleryItemNode(account: self.account) for media in self.message.media { if let image = media as? TelegramMediaImage { - node.setImage(account: account, image: image) + node.setImage(image: image) break } else if let file = media as? TelegramMediaFile, file.mimeType.hasPrefix("image/") { node.setFile(account: account, file: file) break } else if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content { if let image = content.image { - node.setImage(account: account, image: image) + node.setImage(image: image) break } else if let file = content.file, file.mimeType.hasPrefix("image/") { node.setFile(account: account, file: file) @@ -41,27 +41,38 @@ class ChatImageGalleryItem: GalleryItem { node._title.set(.single("\(location.index + 1) of \(location.count)")) } + node.setMessage(self.message) + return node } func updateNode(node: GalleryItemNode) { if let node = node as? ChatImageGalleryItemNode, let location = self.location { node._title.set(.single("\(location.index + 1) of \(location.count)")) + + node.setMessage(self.message) } } } final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode { + private let account: Account + private var message: Message? + private let imageNode: TransformImageNode fileprivate let _ready = Promise() fileprivate let _title = Promise() + private let footerContentNode: ChatItemGalleryFooterContentNode private var accountAndMedia: (Account, Media)? private var fetchDisposable = MetaDisposable() - override init() { + init(account: Account) { + self.account = account + self.imageNode = TransformImageNode() + self.footerContentNode = ChatItemGalleryFooterContentNode(account: account) super.init() @@ -71,11 +82,6 @@ final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode { self.imageNode.view.contentMode = .scaleAspectFill self.imageNode.clipsToBounds = true - - /*self.imageNode.layer.shadowRadius = 80.0 - self.imageNode.layer.shadowColor = UIColor(white: 0.0, alpha: 1.0).cgColor - self.imageNode.layer.shadowOffset = CGSize(width: 0.0, height: 40.0) - self.imageNode.layer.shadowOpacity = 0.5*/ } deinit { @@ -90,7 +96,11 @@ final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode { super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) } - fileprivate func setImage(account: Account, image: TelegramMediaImage) { + fileprivate func setMessage(_ message: Message) { + self.footerContentNode.setMessage(message) + } + + fileprivate func setImage(image: TelegramMediaImage) { if self.accountAndMedia == nil || !self.accountAndMedia!.1.isEqual(image) { if let largestSize = largestRepresentationForPhoto(image) { let displaySize = largestSize.dimensions.fitted(CGSize(width: 1280.0, height: 1280.0)).dividedByScreenScale().integralFloor @@ -98,7 +108,7 @@ final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode { self.imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: displaySize, boundingSize: displaySize, intrinsicInsets: UIEdgeInsets()))() self.imageNode.setSignal(account: account, signal: chatMessagePhoto(account: account, photo: image), dispatchOnDisplayLink: false) self.zoomableContent = (largestSize.dimensions, self.imageNode) - self.fetchDisposable.set(account.postbox.mediaBox.fetchedResource(largestSize.resource).start()) + self.fetchDisposable.set(account.postbox.mediaBox.fetchedResource(largestSize.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .image)).start()) } else { self._ready.set(.single(Void())) } @@ -197,7 +207,7 @@ final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode { if let (account, media) = self.accountAndMedia, let file = media as? TelegramMediaFile { if isVisible { - self.fetchDisposable.set(account.postbox.mediaBox.fetchedResource(file.resource).start()) + self.fetchDisposable.set(account.postbox.mediaBox.fetchedResource(file.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .image)).start()) } else { self.fetchDisposable.set(nil) } @@ -207,4 +217,8 @@ final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode { override func title() -> Signal { return self._title.get() } + + override func footerContent() -> Signal { + return .single(self.footerContentNode) + } } diff --git a/TelegramUI/ChatInterfaceStateContextMenus.swift b/TelegramUI/ChatInterfaceStateContextMenus.swift index 08ba0f693a..c23eccf212 100644 --- a/TelegramUI/ChatInterfaceStateContextMenus.swift +++ b/TelegramUI/ChatInterfaceStateContextMenus.swift @@ -84,6 +84,17 @@ func contextMenuForChatPresentationIntefaceState(_ chatPresentationInterfaceStat } } + for media in message.media { + if let file = media as? TelegramMediaFile { + if file.isVideo && file.isAnimated { + actions.append(ContextMenuAction(content: .text("Save"), action: { + let _ = addSavedGif(postbox: account.postbox, file: file).start() + })) + break + } + } + } + actions.append(ContextMenuAction(content: .text("More..."), action: { interfaceInteraction.beginMessageSelection(message.id) })) diff --git a/TelegramUI/ChatItemGalleryFooterContentNode.swift b/TelegramUI/ChatItemGalleryFooterContentNode.swift new file mode 100644 index 0000000000..555d58eb78 --- /dev/null +++ b/TelegramUI/ChatItemGalleryFooterContentNode.swift @@ -0,0 +1,382 @@ +import Foundation +import AsyncDisplayKit +import Display +import Postbox +import TelegramCore +import SwiftSignalKit +import Photos + +private let deleteImage = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Acessory Panels/MessageSelectionThrash"), color: .white) +private let actionImage = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Acessory Panels/MessageSelectionAction"), color: .white) + +private let textFont = Font.regular(16.0) +private let titleFont = Font.medium(15.0) +private let dateFont = Font.regular(14.0) + +final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode { + private let account: Account + + private let deleteButton: UIButton + private let actionButton: UIButton + private let textNode: ASTextNode + private let authorNameNode: ASTextNode + private let dateNode: ASTextNode + + private var currentMessageText: String? + private var currentAuthorNameText: String? + private var currentDateText: String? + + private var currentMessage: Message? + + private let messageContextDisposable = MetaDisposable() + + init(account: Account) { + self.account = account + + self.deleteButton = UIButton() + self.actionButton = UIButton() + + self.deleteButton.setImage(deleteImage, for: [.normal]) + self.actionButton.setImage(actionImage, for: [.normal]) + + self.textNode = ASTextNode() + self.authorNameNode = ASTextNode() + self.authorNameNode.maximumNumberOfLines = 1 + self.dateNode = ASTextNode() + self.dateNode.maximumNumberOfLines = 1 + + super.init() + + self.view.addSubview(self.deleteButton) + self.view.addSubview(self.actionButton) + self.addSubnode(self.textNode) + self.addSubnode(self.authorNameNode) + self.addSubnode(self.dateNode) + + self.deleteButton.addTarget(self, action: #selector(self.deleteButtonPressed), for: [.touchUpInside]) + self.actionButton.addTarget(self, action: #selector(self.actionButtonPressed), for: [.touchUpInside]) + } + + deinit { + self.messageContextDisposable.dispose() + } + + func setMessage(_ message: Message) { + self.currentMessage = message + + let canDelete: Bool + if let peer = message.peers[message.id.peerId] { + if let _ = peer as? TelegramUser { + canDelete = true + } else if let _ = peer as? TelegramGroup { + canDelete = true + } else if let channel = peer as? TelegramChannel { + if message.flags.contains(.Incoming) { + switch channel.role { + case .creator, .moderator, .editor: + canDelete = true + case .member: + canDelete = false + } + } else { + canDelete = true + } + } else { + canDelete = false + } + } else { + canDelete = false + } + + var authorNameText: String? + + if let author = message.author { + authorNameText = author.displayTitle + } else if let peer = message.peers[message.id.peerId] { + authorNameText = peer.displayTitle + } + + let dateText = humanReadableStringForTimestamp(timestamp: message.timestamp) + + if self.currentMessageText != message.text || canDelete != !self.deleteButton.isHidden || self.currentAuthorNameText != authorNameText || self.currentDateText != dateText { + self.currentMessageText = message.text + + if message.text.isEmpty { + self.textNode.isHidden = true + self.textNode.attributedText = nil + } else { + self.textNode.isHidden = false + self.textNode.attributedText = NSAttributedString(string: message.text, font: textFont, textColor: .white) + } + + if let authorNameText = authorNameText { + self.authorNameNode.attributedText = NSAttributedString(string: authorNameText, font: titleFont, textColor: .white) + } else { + self.authorNameNode.attributedText = nil + } + self.dateNode.attributedText = NSAttributedString(string: dateText, font: dateFont, textColor: .white) + + self.deleteButton.isHidden = !canDelete + + self.requestLayout?(.immediate) + } + } + + override func updateLayout(width: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { + var panelHeight: CGFloat = 44.0 + if !self.textNode.isHidden { + let sideInset: CGFloat = 8.0 + let topInset: CGFloat = 8.0 + let bottomInset: CGFloat = 8.0 + let textSize = self.textNode.measure(CGSize(width: width - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude)) + panelHeight += textSize.height + topInset + bottomInset + transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: sideInset, y: topInset), size: textSize)) + } + + self.actionButton.frame = CGRect(origin: CGPoint(x: 0.0, y: panelHeight - 44.0), size: CGSize(width: 44.0, height: 44.0)) + self.deleteButton.frame = CGRect(origin: CGPoint(x: width - 44.0, y: panelHeight - 44.0), size: CGSize(width: 44.0, height: 44.0)) + + let authorNameSize = self.authorNameNode.measure(CGSize(width: width - 44.0 * 2.0 - 8.0 * 2.0, height: CGFloat.greatestFiniteMagnitude)) + let dateSize = self.dateNode.measure(CGSize(width: width - 44.0 * 2.0 - 8.0 * 2.0, height: CGFloat.greatestFiniteMagnitude)) + + if authorNameSize.height.isZero { + transition.updateFrame(node: self.dateNode, frame: CGRect(origin: CGPoint(x: floor((width - dateSize.width) / 2.0), y: panelHeight - 44.0 + floor((44.0 - dateSize.height) / 2.0)), size: dateSize)) + } else { + let labelsSpacing: CGFloat = 0.0 + transition.updateFrame(node: self.authorNameNode, frame: CGRect(origin: CGPoint(x: floor((width - authorNameSize.width) / 2.0), y: panelHeight - 44.0 + floor((44.0 - dateSize.height - authorNameSize.height - labelsSpacing) / 2.0)), size: authorNameSize)) + transition.updateFrame(node: self.dateNode, frame: CGRect(origin: CGPoint(x: floor((width - dateSize.width) / 2.0), y: panelHeight - 44.0 + floor((44.0 - dateSize.height - authorNameSize.height - labelsSpacing) / 2.0) + authorNameSize.height + labelsSpacing), size: dateSize)) + } + + return panelHeight + } + + @objc func deleteButtonPressed() { + if let currentMessage = self.currentMessage { + self.messageContextDisposable.set((chatDeleteMessagesOptions(account: self.account, messageIds: [currentMessage.id]) |> deliverOnMainQueue).start(next: { [weak self] options in + if let strongSelf = self, let controllerInteration = strongSelf.controllerInteraction, !options.isEmpty { + let actionSheet = ActionSheetController() + var items: [ActionSheetItem] = [] + var personalPeerName: String? + var isChannel = false + if let user = currentMessage.peers[currentMessage.id.peerId] as? TelegramUser { + personalPeerName = user.compactDisplayTitle + } else if let channel = currentMessage.peers[currentMessage.id.peerId] as? TelegramChannel, case .broadcast = channel.info { + isChannel = true + } + + if options.contains(.globally) { + let globalTitle: String + if isChannel { + globalTitle = "Delete" + } else if let personalPeerName = personalPeerName { + globalTitle = "Delete for me and \(personalPeerName)" + } else { + globalTitle = "Delete for everyone" + } + items.append(ActionSheetButtonItem(title: globalTitle, color: .destructive, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + if let strongSelf = self { + let _ = deleteMessagesInteractively(postbox: strongSelf.account.postbox, messageIds: [currentMessage.id], type: .forEveryone).start() + strongSelf.controllerInteraction?.dismissController() + } + })) + } + if options.contains(.locally) { + items.append(ActionSheetButtonItem(title: "Delete for me", color: .destructive, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + if let strongSelf = self { + let _ = deleteMessagesInteractively(postbox: strongSelf.account.postbox, messageIds: [currentMessage.id], type: .forLocalPeer).start() + strongSelf.controllerInteraction?.dismissController() + } + })) + } + actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "Cancel", color: .accent, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ])]) + controllerInteration.presentController(actionSheet, nil) + } + })) + } + } + + @objc func actionButtonPressed() { + if let controllerInteraction = self.controllerInteraction, let currentMessage = self.currentMessage { + var saveToCameraRoll: (() -> Void)? + var shareAction: (([PeerId]) -> Void)? + let shareController = ShareController(account: self.account, shareAction: { peerIds in + shareAction?(peerIds) + }, defaultAction: ShareControllerAction(title: "Save to Camera Roll", action: { + saveToCameraRoll?() + })) + controllerInteraction.presentController(shareController, nil) + shareAction = { [weak shareController, weak self] peerIds in + shareController?.dismiss() + + if let strongSelf = self, let currentMessage = strongSelf.currentMessage { + for peerId in peerIds { + let _ = enqueueMessages(account: strongSelf.account, peerId: peerId, messages: [.forward(source: currentMessage.id)]).start() + } + } + } + saveToCameraRoll = { [weak shareController, weak self] in + shareController?.dismiss() + + if let strongSelf = self, let currentMessage = strongSelf.currentMessage { + var resource: (MediaResource, Bool)? + for media in currentMessage.media { + if let image = media as? TelegramMediaImage { + if let representation = largestImageRepresentation(image.representations) { + resource = (representation.resource, true) + } + break + } else if let file = media as? TelegramMediaFile { + if file.isVideo { + resource = (file.resource, false) + } else if file.mimeType.hasPrefix("image/") { + resource = (file.resource, true) + } + break + } + } + + if let (resource, isImage) = resource { + strongSelf.messageContextDisposable.set((strongSelf.account.postbox.mediaBox.resourceData(resource, option: .complete(waitUntilFetchStatus: true)) |> take(1) |> deliverOnMainQueue).start(next: { data in + if data.complete { + let tempVideoPath = NSTemporaryDirectory() + "\(arc4random64()).mp4" + PHPhotoLibrary.shared().performChanges({ + if isImage { + if let data = try? Data(contentsOf: URL(fileURLWithPath: data.path)), let image = UIImage(data: data) { + PHAssetChangeRequest.creationRequestForAsset(from: image) + //PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: URL(fileURLWithPath: data.path)) + } + } else { + if let _ = try? FileManager.default.copyItem(atPath: data.path, toPath: tempVideoPath) { + PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: URL(fileURLWithPath: tempVideoPath)) + } + } + }, completionHandler: { _, error in + if let error = error { + print("\(error)") + } + let _ = try? FileManager.default.removeItem(atPath: tempVideoPath) + }) + } + })) + } + } + } + return + + let actionSheet = ActionSheetController() + var items: [ActionSheetItem] = [] + + var canSaveToCameraRoll = false + for media in currentMessage.media { + if let _ = media as? TelegramMediaImage { + canSaveToCameraRoll = true + break + } else if let file = media as? TelegramMediaFile { + if file.isVideo { + canSaveToCameraRoll = true + } else if file.mimeType.hasPrefix("image/") { + canSaveToCameraRoll = true + } + break + } + } + + if canSaveToCameraRoll { + items.append(ActionSheetButtonItem(title: "Save to Camera Roll", color: .accent, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + if let strongSelf = self, let currentMessage = strongSelf.currentMessage { + var resource: (MediaResource, Bool)? + for media in currentMessage.media { + if let image = media as? TelegramMediaImage { + if let representation = largestImageRepresentation(image.representations) { + resource = (representation.resource, true) + } + break + } else if let file = media as? TelegramMediaFile { + if file.isVideo { + resource = (file.resource, false) + } else if file.mimeType.hasPrefix("image/") { + resource = (file.resource, true) + } + break + } + } + + if let (resource, isImage) = resource { + strongSelf.messageContextDisposable.set((strongSelf.account.postbox.mediaBox.resourceData(resource, option: .complete(waitUntilFetchStatus: true)) |> take(1) |> deliverOnMainQueue).start(next: { data in + if data.complete { + let tempVideoPath = NSTemporaryDirectory() + "\(arc4random64()).mp4" + PHPhotoLibrary.shared().performChanges({ + if isImage { + if let data = try? Data(contentsOf: URL(fileURLWithPath: data.path)), let image = UIImage(data: data) { + PHAssetChangeRequest.creationRequestForAsset(from: image) + //PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: URL(fileURLWithPath: data.path)) + } + } else { + if let _ = try? FileManager.default.copyItem(atPath: data.path, toPath: tempVideoPath) { + PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: URL(fileURLWithPath: tempVideoPath)) + } + } + }, completionHandler: { _, error in + if let error = error { + print("\(error)") + } + let _ = try? FileManager.default.removeItem(atPath: tempVideoPath) + }) + } + })) + } + } + })) + } + + items.append(ActionSheetButtonItem(title: "Forward", color: .accent, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + if let strongSelf = self, let currentMessage = strongSelf.currentMessage { + let forwardMessageIds = [currentMessage.id] + + let controller = PeerSelectionController(account: strongSelf.account) + controller.peerSelected = { [weak controller] peerId in + if let strongSelf = self, let strongController = controller { + let _ = (strongSelf.account.postbox.modify({ modifier -> Void in + modifier.updatePeerChatInterfaceState(peerId, update: { currentState in + if let currentState = currentState as? ChatInterfaceState { + return currentState.withUpdatedForwardMessageIds(forwardMessageIds) + } else { + return ChatInterfaceState().withUpdatedForwardMessageIds(forwardMessageIds) + } + }) + }) |> deliverOnMainQueue).start(completed: { + if let strongSelf = self { + let ready = ValuePromise() + + strongSelf.messageContextDisposable.set((ready.get() |> take(1) |> deliverOnMainQueue).start(next: { _ in + if let strongController = controller { + strongController.dismiss() + self?.controllerInteraction?.dismissController() + } + })) + + strongSelf.controllerInteraction?.replaceRootController(ChatController(account: strongSelf.account, peerId: peerId), ready) + } + }) + } + } + strongSelf.controllerInteraction?.presentController(controller, nil) + } + })) + actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "Cancel", color: .accent, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ])]) + controllerInteraction.presentController(actionSheet, nil) + } + } +} diff --git a/TelegramUI/ChatItemGalleryItemNode.swift b/TelegramUI/ChatItemGalleryItemNode.swift new file mode 100644 index 0000000000..99761d7be2 --- /dev/null +++ b/TelegramUI/ChatItemGalleryItemNode.swift @@ -0,0 +1,5 @@ +import Foundation +import Postbox +import TelegramCore + + diff --git a/TelegramUI/ChatListController.swift b/TelegramUI/ChatListController.swift index d6a18f3d7b..99d3d185f3 100644 --- a/TelegramUI/ChatListController.swift +++ b/TelegramUI/ChatListController.swift @@ -27,6 +27,8 @@ public class ChatListController: TelegramController, UIViewControllerPreviewingD private var didSetup3dTouch = false + private let passcodeDisposable = MetaDisposable() + public override init(account: Account) { self.account = account @@ -83,6 +85,29 @@ public class ChatListController: TelegramController, UIViewControllerPreviewingD } } }) + + self.passcodeDisposable.set((account.postbox.combinedView(keys: [.accessChallengeData]) |> deliverOnMainQueue).start(next: { [weak self] view in + if let strongSelf = self { + let data = (view.views[.accessChallengeData] as! AccessChallengeDataView).data + strongSelf.titleView.updatePasscode(isPasscodeSet: data.isLockable, isManuallyLocked: data.autolockDeadline == 0) + } + })) + + self.titleView.toggleIsLocked = { [weak self] in + if let strongSelf = self { + let _ = strongSelf.account.postbox.modify({ modifier -> Void in + var data = modifier.getAccessChallengeData() + if data.isLockable { + if data.autolockDeadline != 0 { + data = data.withUpdatedAutolockDeadline(0) + } else { + data = data.withUpdatedAutolockDeadline(nil) + } + modifier.setAccessChallengeData(data) + } + }).start() + } + } } required public init(coder aDecoder: NSCoder) { @@ -93,6 +118,7 @@ public class ChatListController: TelegramController, UIViewControllerPreviewingD self.openMessageFromSearchDisposable.dispose() self.titleDisposable?.dispose() self.badgeDisposable?.dispose() + self.passcodeDisposable.dispose() } override public func loadDisplayNode() { diff --git a/TelegramUI/ChatListItem.swift b/TelegramUI/ChatListItem.swift index dcb3279225..8d320348d4 100644 --- a/TelegramUI/ChatListItem.swift +++ b/TelegramUI/ChatListItem.swift @@ -116,7 +116,7 @@ class ChatListItem: ListViewItem { } } -private let titleFont = Font.medium(17.0) +private let titleFont = Font.semibold(17.0) private let textFont = Font.regular(15.0) private let dateFont = Font.regular(14.0) private let badgeFont = Font.regular(14.0) @@ -143,6 +143,8 @@ private let muteOption = ItemListRevealOption(key: RevealOptionKey.mute.rawValue private let unmuteOption = ItemListRevealOption(key: RevealOptionKey.unmute.rawValue, title: "Unmute", icon: unmuteIcon, color: UIColor(0xaaaab3)) private let deleteOption = ItemListRevealOption(key: RevealOptionKey.delete.rawValue, title: "Delete", icon: deleteIcon, color: UIColor(0xff3824)) +private let itemHeight: CGFloat = 76.0 //68.0 + private func revealOptions(isPinned: Bool, isMuted: Bool) -> [ItemListRevealOption] { var options: [ItemListRevealOption] = [] if isPinned { @@ -189,7 +191,7 @@ private func generateBadgeBackgroundImage(active: Bool) -> UIImage? { if active { context.setFillColor(UIColor(0x007ee5).cgColor) } else { - context.setFillColor(UIColor(0xbbbbbb).cgColor) + context.setFillColor(UIColor(0xadb3bb).cgColor) } context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) })?.stretchableImage(withLeftCapWidth: 10, topCapHeight: 10) @@ -205,6 +207,8 @@ private let separatorHeight = 1.0 / UIScreen.main.scale private let pinnedBackgroundColor = UIColor(0xf7f7f7) +private let avatarFont: UIFont = UIFont(name: "ArialRoundedMTBold", size: 24.0)! + class ChatListItemNode: ItemListRevealOptionsItemNode { var item: ChatListItem? @@ -213,6 +217,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { let avatarNode: AvatarNode let titleNode: TextNode + let authorNode: TextNode let textNode: TextNode let dateNode: TextNode let statusNode: ASImageNode @@ -239,7 +244,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { self.backgroundNode.displaysAsynchronously = false self.backgroundNode.backgroundColor = .white - self.avatarNode = AvatarNode(font: Font.regular(24.0)) + self.avatarNode = AvatarNode(font: avatarFont) self.avatarNode.isLayerBacked = true self.highlightedBackgroundNode = ASDisplayNode() @@ -250,6 +255,10 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { self.titleNode.isLayerBacked = true self.titleNode.displaysAsynchronously = true + self.authorNode = TextNode() + self.authorNode.isLayerBacked = true + self.authorNode.displaysAsynchronously = true + self.textNode = TextNode() self.textNode.isLayerBacked = true self.textNode.displaysAsynchronously = true @@ -288,6 +297,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { self.addSubnode(self.avatarNode) self.addSubnode(self.titleNode) + self.addSubnode(self.authorNode) self.addSubnode(self.textNode) self.addSubnode(self.dateNode) self.addSubnode(self.statusNode) @@ -379,6 +389,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { let dateLayout = TextNode.asyncLayout(self.dateNode) let textLayout = TextNode.asyncLayout(self.textNode) let titleLayout = TextNode.asyncLayout(self.titleNode) + let authorLayout = TextNode.asyncLayout(self.authorNode) let badgeTextLayout = TextNode.asyncLayout(self.badgeTextNode) let editableControlLayout = ItemListEditableControlNode.asyncLayout(self.editableControlNode) @@ -389,6 +400,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { let notificationSettings = item.notificationSettings let embeddedState = item.embeddedState + var authorAttributedString: NSAttributedString? var textAttributedString: NSAttributedString? var dateAttributedString: NSAttributedString? var titleAttributedString: NSAttributedString? @@ -402,7 +414,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { let editingOffset: CGFloat if item.editing { - let sizeAndApply = editableControlLayout(68.0) + let sizeAndApply = editableControlLayout(itemHeight) editableControlSizeAndApply = sizeAndApply editingOffset = sizeAndApply.0.width } else { @@ -412,7 +424,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { if true { let peer: Peer? - var messageText: NSString + var messageText: String if let message = message { if let messageMain = messageMainPeer(message) { peer = messageMain @@ -420,7 +432,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { peer = item.peer.chatMainPeer } - messageText = message.text as NSString + messageText = message.text if message.text.isEmpty { for media in message.media { switch media { @@ -448,20 +460,22 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { let attributedText: NSAttributedString 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(0x8e8e93))) - attributedText = mutableAttributedText; + authorAttributedString = NSAttributedString(string: "Draft", font: textFont, textColor: UIColor(0xdd4b39)) + + attributedText = NSAttributedString(string: embeddedState.text, font: textFont, textColor: UIColor(0x8e8e93)) } 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 { attributedText = NSAttributedString(string: messageText as String, font: textFont, textColor: UIColor(0x8e8e93)) } else { - let peerText: NSString = (author.id == account.peerId ? "You: " : author.compactDisplayTitle + ": ") as NSString + let peerText: String = author.id == account.peerId ? "You" : author.displayTitle - let mutableAttributedText = NSMutableAttributedString(string: peerText.appending(messageText as String), attributes: [kCTFontAttributeName as String: textFont]) + authorAttributedString = NSAttributedString(string: peerText, font: textFont, textColor: .black) + attributedText = NSAttributedString(string: messageText, font: textFont, textColor: UIColor(0x8e8e93)) + + /*let mutableAttributedText = NSMutableAttributedString(string: peerText.appending(messageText as String), attributes: [kCTFontAttributeName as String: textFont]) mutableAttributedText.addAttribute(kCTForegroundColorAttributeName as String, value: UIColor.black.cgColor, range: NSMakeRange(0, peerText.length)) mutableAttributedText.addAttribute(kCTForegroundColorAttributeName as String, value: UIColor(0x8e8e93).cgColor, range: NSMakeRange(peerText.length, messageText.length)) - attributedText = mutableAttributedText; + attributedText = mutableAttributedText*/ } } else { attributedText = NSAttributedString(string: messageText as String, font: textFont, textColor: UIColor(0x8e8e93)) @@ -522,7 +536,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { muteWidth = currentMutedIconImage.size.width + 4.0 } - let rawContentRect = CGRect(origin: CGPoint(x: 2.0, y: 12.0), size: CGSize(width: width - 78.0 - 10.0 - 1.0 - editingOffset, height: 68.0 - 12.0 - 9.0)) + let rawContentRect = CGRect(origin: CGPoint(x: 2.0, y: 8.0), size: CGSize(width: width - 78.0 - 10.0 - 1.0 - editingOffset, height: itemHeight - 12.0 - 9.0)) let (dateLayout, dateApply) = dateLayout(dateAttributedString, nil, 1, .end, CGSize(width: rawContentRect.width, height: CGFloat.greatestFiniteMagnitude), .natural, nil, UIEdgeInsets()) @@ -530,18 +544,20 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { let badgeSize: CGFloat if let currentBadgeBackgroundImage = currentBadgeBackgroundImage { - badgeSize = max(currentBadgeBackgroundImage.size.width, badgeLayout.size.width + 10.0) + 2.0 + badgeSize = max(currentBadgeBackgroundImage.size.width, badgeLayout.size.width + 10.0) + 5.0 } else { badgeSize = 0.0 } - let (textLayout, textApply) = textLayout(textAttributedString, nil, 1, .end, CGSize(width: rawContentRect.width - badgeSize, height: CGFloat.greatestFiniteMagnitude), .natural, nil, UIEdgeInsets(top: 2.0, left: 1.0, bottom: 2.0, right: 1.0)) + let (authorLayout, authorApply) = authorLayout(authorAttributedString, nil, 1, .end, CGSize(width: rawContentRect.width - badgeSize, height: CGFloat.greatestFiniteMagnitude), .natural, nil, UIEdgeInsets(top: 2.0, left: 1.0, bottom: 2.0, right: 1.0)) + + let (textLayout, textApply) = textLayout(textAttributedString, nil, authorAttributedString == nil ? 2 : 1, .end, CGSize(width: rawContentRect.width - badgeSize, height: CGFloat.greatestFiniteMagnitude), .natural, nil, UIEdgeInsets(top: 2.0, left: 1.0, bottom: 2.0, right: 1.0)) let titleRect = CGRect(origin: rawContentRect.origin, size: CGSize(width: rawContentRect.width - dateLayout.size.width - 10.0 - statusWidth - muteWidth, height: rawContentRect.height)) let (titleLayout, titleApply) = titleLayout(titleAttributedString, nil, 1, .end, CGSize(width: titleRect.width, height: CGFloat.greatestFiniteMagnitude), .natural, nil, UIEdgeInsets()) let insets = ChatListItemNode.insets(first: first, last: last, firstWithHeader: firstWithHeader) - let layout = ListViewItemNodeLayout(contentSize: CGSize(width: width, height: 68.0), insets: insets) + let layout = ListViewItemNodeLayout(contentSize: CGSize(width: width, height: itemHeight), insets: insets) let peerRevealOptions = revealOptions(isPinned: item.index.pinningIndex != nil, isMuted: currentMutedIconImage != nil) @@ -588,10 +604,11 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { }) } - transition.updateFrame(node: strongSelf.avatarNode, frame: CGRect(origin: CGPoint(x: editingOffset + 10.0 + revealOffset, y: 4.0), size: CGSize(width: 60.0, height: 60.0))) + transition.updateFrame(node: strongSelf.avatarNode, frame: CGRect(origin: CGPoint(x: editingOffset + 10.0 + revealOffset, y: 7.0), size: CGSize(width: 60.0, height: 60.0))) let _ = dateApply() let _ = textApply() + let _ = authorApply() let _ = titleApply() let _ = badgeApply() @@ -635,7 +652,8 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { let contentDeltaX = contentRect.origin.x - strongSelf.titleNode.frame.minX strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: contentRect.origin.x, y: contentRect.origin.y), size: titleLayout.size) - strongSelf.textNode.frame = CGRect(origin: CGPoint(x: contentRect.origin.x - 1.0, y: contentRect.maxY - textLayout.size.height - 1.0), size: textLayout.size) + strongSelf.authorNode.frame = CGRect(origin: CGPoint(x: contentRect.origin.x - 1.0, y: contentRect.minY + titleLayout.size.height - 1.0), size: authorLayout.size) + strongSelf.textNode.frame = CGRect(origin: CGPoint(x: contentRect.origin.x - 1.0, y: contentRect.minY + titleLayout.size.height - 1.0 + (authorLayout.size.height.isZero ? 0.0 : (authorLayout.size.height - 3.0))), size: textLayout.size) if !contentDeltaX.isZero { let titlePosition = strongSelf.titleNode.position @@ -643,6 +661,9 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { let textPosition = strongSelf.textNode.position transition.animatePosition(node: strongSelf.textNode, from: CGPoint(x: textPosition.x - contentDeltaX, y: textPosition.y)) + + let authorPosition = strongSelf.authorNode.position + transition.animatePosition(node: strongSelf.authorNode, from: CGPoint(x: authorPosition.x - contentDeltaX, y: authorPosition.y)) } let separatorInset: CGFloat @@ -652,7 +673,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { separatorInset = editingOffset + 78.0 + rawContentRect.origin.x } - transition.updateFrame(node: strongSelf.separatorNode, frame: CGRect(origin: CGPoint(x: separatorInset, y: 68.0 - separatorHeight), size: CGSize(width: width - separatorInset, height: separatorHeight))) + transition.updateFrame(node: strongSelf.separatorNode, frame: CGRect(origin: CGPoint(x: separatorInset, y: itemHeight - separatorHeight), size: CGSize(width: width - separatorInset, height: separatorHeight))) strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(), size: layout.contentSize) if item.index.pinningIndex != nil { @@ -722,7 +743,7 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { editingOffset = 0.0 } - let rawContentRect = CGRect(origin: CGPoint(x: 2.0, y: 12.0), size: CGSize(width: self.contentSize.width - 78.0 - 10.0 - 1.0 - editingOffset, height: 68.0 - 12.0 - 9.0)) + let rawContentRect = CGRect(origin: CGPoint(x: 2.0, y: 8.0), size: CGSize(width: self.contentSize.width - 78.0 - 10.0 - 1.0 - editingOffset, height: itemHeight - 12.0 - 9.0)) let contentRect = rawContentRect.offsetBy(dx: editingOffset + 78.0 + offset, dy: 0.0) @@ -733,6 +754,9 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { let titleFrame = self.titleNode.frame transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: contentRect.origin.x, y: titleFrame.origin.y), size: titleFrame.size)) + let authorFrame = self.authorNode.frame + transition.updateFrame(node: self.authorNode, frame: CGRect(origin: CGPoint(x: contentRect.origin.x - 1.0, y: authorFrame.origin.y), size: authorFrame.size)) + let textFrame = self.textNode.frame transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: contentRect.origin.x - 1.0, y: textFrame.origin.y), size: textFrame.size)) diff --git a/TelegramUI/ChatListTitleLockView.swift b/TelegramUI/ChatListTitleLockView.swift new file mode 100644 index 0000000000..eb8ff0a17f --- /dev/null +++ b/TelegramUI/ChatListTitleLockView.swift @@ -0,0 +1,88 @@ +import UIKit +import Display + +private let topLockedImage = generateTintedImage(image: UIImage(bundleImageName: "Chat List/LockLockedTop"), color: UIColor(0x007ee5)) +private let topUnlockedImage = UIImage(bundleImageName: "Chat List/LockUnlockedTop")?.preloaded() +private let bottomLockedImage = generateTintedImage(image: UIImage(bundleImageName: "Chat List/LockLockedBottom"), color: UIColor(0x007ee5)) +private let bottomUnlockedImage = UIImage(bundleImageName: "Chat List/LockUnlockedBottom")?.preloaded() + +final class ChatListTitleLockView: UIView { + private let topView: UIImageView + private let bottomView: UIImageView + + private var isLocked: Bool = false + + override init(frame: CGRect) { + self.topView = UIImageView() + self.bottomView = UIImageView() + + super.init(frame: frame) + + self.addSubview(self.topView) + self.addSubview(self.bottomView) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setIsLocked(_ isLocked: Bool, animated: Bool) { + self.isLocked = isLocked + if animated { + let topViewCopy = UIImageView(image: self.topView.image) + topViewCopy.frame = self.topView.frame + self.addSubview(topViewCopy) + + let bottomViewCopy = UIImageView(image: self.bottomView.image) + bottomViewCopy.frame = self.bottomView.frame + self.addSubview(bottomViewCopy) + + self.topView.image = self.isLocked ? topLockedImage : topUnlockedImage + self.bottomView.image = self.isLocked ? bottomLockedImage : bottomUnlockedImage + + self.topView.alpha = 0.5 + self.bottomView.alpha = 0.5 + + let block: () -> Void = { + self.layoutItems() + topViewCopy.frame = self.topView.frame + bottomViewCopy.frame = self.bottomView.frame + } + + UIView.animate(withDuration: 0.1, animations: { + topViewCopy.alpha = 0.0 + bottomViewCopy.alpha = 0.0 + + self.topView.alpha = 1.0 + self.bottomView.alpha = 1.0 + }) + + UIView.animate(withDuration: 0.3, delay: 0.0, usingSpringWithDamping: 0.39, initialSpringVelocity: 0.0, options: [], animations: { + block() + }, completion: { _ in + topViewCopy.removeFromSuperview() + bottomViewCopy.removeFromSuperview() + }) + } else { + self.topView.image = self.isLocked ? topLockedImage : topUnlockedImage + self.bottomView.image = self.isLocked ? bottomLockedImage : bottomUnlockedImage + self.layoutItems() + } + } + + private func layoutItems() { + if self.isLocked { + self.topView.frame = CGRect(x: (10.0 - 7.0) / 2.0, y: 0.0, width: 7.0, height: 5.0) + self.bottomView.frame = CGRect(x: 0.0, y: 5.0, width: 10.0, height: 7.0) + } else { + self.topView.frame = CGRect(x: 6.0, y: 0.0, width: 7.0, height: 5.0) + self.bottomView.frame = CGRect(x: 0.0, y: 5.0, width: 10.0, height: 7.0) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + self.layoutItems() + } +} diff --git a/TelegramUI/ChatMediaActionSheetRollItem.swift b/TelegramUI/ChatMediaActionSheetRollItem.swift index fe9e3b2576..5c414de571 100644 --- a/TelegramUI/ChatMediaActionSheetRollItem.swift +++ b/TelegramUI/ChatMediaActionSheetRollItem.swift @@ -15,6 +15,9 @@ final class ChatMediaActionSheetRollItem: ActionSheetItem { func node() -> ActionSheetItemNode { return ChatMediaActionSheetRollItemNode(assetSelected: self.assetSelected) } + + func updateNode(_ node: ActionSheetItemNode) { + } } private final class ChatMediaActionSheetRollItemNode: ActionSheetItemNode, PHPhotoLibraryChangeObserver { diff --git a/TelegramUI/ChatMediaInputGifPane.swift b/TelegramUI/ChatMediaInputGifPane.swift new file mode 100644 index 0000000000..61952faf9a --- /dev/null +++ b/TelegramUI/ChatMediaInputGifPane.swift @@ -0,0 +1,64 @@ +import Foundation +import AsyncDisplayKit +import Display +import Postbox +import TelegramCore +import SwiftSignalKit + +final class ChatMediaInputGifPane: ASDisplayNode, UIScrollViewDelegate { + private let multiplexedNode: MultiplexedVideoNode + + private let disposable = MetaDisposable() + + init(account: Account, controllerInteraction: ChatControllerInteraction) { + self.multiplexedNode = MultiplexedVideoNode(account: account) + + super.init() + + self.view.addSubview(self.multiplexedNode) + let initialOrder = Atomic<[MediaId]?>(value: nil) + let gifs = account.postbox.combinedView(keys: [.orderedItemList(id: Namespaces.OrderedItemList.CloudRecentGifs)]) + |> map { view -> [TelegramMediaFile] in + var recentGifs: OrderedItemListView? + if let orderedView = view.views[.orderedItemList(id: Namespaces.OrderedItemList.CloudRecentGifs)] { + recentGifs = orderedView as? OrderedItemListView + } + if let recentGifs = recentGifs { + return recentGifs.items.map { ($0.contents as! RecentMediaItem).media as! TelegramMediaFile } + } else { + return [] + } + } + self.disposable.set((gifs |> deliverOnMainQueue).start(next: { [weak self] gifs in + if let strongSelf = self { + strongSelf.multiplexedNode.files = gifs + } + })) + + self.multiplexedNode.fileSelected = { file in + controllerInteraction.sendGif(file) + } + self.multiplexedNode.fileLongPressed = { [weak self] file in + if let strongSelf = self, let itemFrame = strongSelf.multiplexedNode.frameForItem(file.fileId) { + let contextMenuController = ContextMenuController(actions: [ContextMenuAction(content: .text("Delete"), action: { + let _ = removeSavedGif(postbox: account.postbox, mediaId: file.fileId).start() + })]) + controllerInteraction.presentController(contextMenuController, ContextMenuControllerPresentationArguments(sourceNodeAndRect: { + if let strongSelf = self { + return (strongSelf, strongSelf.multiplexedNode.convert(itemFrame, to: strongSelf.view).insetBy(dx: -2.0, dy: -2.0).offsetBy(dx: strongSelf.multiplexedNode.frame.minX, dy: strongSelf.multiplexedNode.frame.minY)) + } else { + return nil + } + })) + } + } + } + + deinit { + self.disposable.dispose() + } + + func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { + self.multiplexedNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height)) + } +} diff --git a/TelegramUI/ChatMediaInputNode.swift b/TelegramUI/ChatMediaInputNode.swift index 2aa4a5b765..b7e86edd3c 100644 --- a/TelegramUI/ChatMediaInputNode.swift +++ b/TelegramUI/ChatMediaInputNode.swift @@ -83,6 +83,7 @@ private func preparedChatMediaInputGridEntryTransition(account: Account, from fr private func chatMediaInputPanelEntries(view: ItemCollectionsView, recentStickers: OrderedItemListView?) -> [ChatMediaInputPanelEntry] { var entries: [ChatMediaInputPanelEntry] = [] + entries.append(.recentGifs) if let recentStickers = recentStickers, !recentStickers.items.isEmpty { entries.append(.recentPacks) } @@ -107,7 +108,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, count: 0) + let packInfo = StickerPackCollectionInfo(id: ItemCollectionId(namespace: ChatMediaInputPanelAuxiliaryNamespace.recentStickers.rawValue, 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) @@ -159,7 +160,9 @@ private enum StickerPacksCollectionUpdate { final class ChatMediaInputNodeInteraction { let navigateToCollectionId: (ItemCollectionId) -> Void + var highlightedStickerItemCollectionId: ItemCollectionId? var highlightedItemCollectionId: ItemCollectionId? + var previewedStickerPackItem: StickerPackItem? init(navigateToCollectionId: @escaping (ItemCollectionId) -> Void) { self.navigateToCollectionId = navigateToCollectionId @@ -169,7 +172,7 @@ final class ChatMediaInputNodeInteraction { private func clipScrollPosition(_ position: StickerPacksCollectionPosition) -> StickerPacksCollectionPosition { switch position { case let .scroll(index): - if let index = index, index.collectionId.namespace == Namespaces.ItemCollection.CloudRecentStickers { + if let index = index, index.collectionId.namespace == ChatMediaInputPanelAuxiliaryNamespace.recentStickers.rawValue { return .scroll(aroundIndex: nil) } default: @@ -181,6 +184,25 @@ private func clipScrollPosition(_ position: StickerPacksCollectionPosition) -> S private let defaultPortraitPanelHeight: CGFloat = UIScreenScale.isEqual(to: 3.0) ? 271.0 : 258.0 private let defaultLandscapePanelHeight: CGFloat = UIScreenScale.isEqual(to: 3.0) ? 194.0 : 194.0 +private enum ChatMediaInputPane { + case gifs + case stickers +} + +private struct ChatMediaInputPaneArrangement { + let panes: [ChatMediaInputPane] + let currentIndex: Int + let indexTransition: CGFloat + + func withIndexTransition(_ indexTransition: CGFloat) -> ChatMediaInputPaneArrangement { + return ChatMediaInputPaneArrangement(panes: self.panes, currentIndex: currentIndex, indexTransition: indexTransition) + } + + func withCurrentIndex(_ currentIndex: Int) -> ChatMediaInputPaneArrangement { + return ChatMediaInputPaneArrangement(panes: self.panes, currentIndex: currentIndex, indexTransition: self.indexTransition) + } +} + final class ChatMediaInputNode: ChatInputNode { private let account: Account private let controllerInteraction: ChatControllerInteraction @@ -193,12 +215,19 @@ final class ChatMediaInputNode: ChatInputNode { private let disposable = MetaDisposable() private let listView: ListView - private let gridNode: GridNode + + private let stickerPane: ChatMediaInputStickerPane + private let gifPane: ChatMediaInputGifPane private let itemCollectionsViewPosition = Promise() private var currentStickerPacksCollectionPosition: StickerPacksCollectionPosition? private var currentView: ItemCollectionsView? + private var stickerPreviewController: StickerPreviewController? + + private var validLayout: (CGFloat, ChatPresentationInterfaceState)? + private var paneArrangement: ChatMediaInputPaneArrangement + init(account: Account, controllerInteraction: ChatControllerInteraction) { self.account = account self.controllerInteraction = controllerInteraction @@ -213,16 +242,23 @@ final class ChatMediaInputNode: ChatInputNode { self.listView = ListView() self.listView.transform = CATransform3DMakeRotation(-CGFloat(Double.pi / 2.0), 0.0, 0.0, 1.0) - self.gridNode = GridNode() + self.stickerPane = ChatMediaInputStickerPane() + self.gifPane = ChatMediaInputGifPane(account: account, controllerInteraction: controllerInteraction) + + self.paneArrangement = ChatMediaInputPaneArrangement(panes: [.gifs, .stickers], currentIndex: 1, indexTransition: 0.0) super.init() self.inputNodeInteraction = ChatMediaInputNodeInteraction(navigateToCollectionId: { [weak self] collectionId in if let strongSelf = self, let currentView = strongSelf.currentView, (collectionId != strongSelf.inputNodeInteraction.highlightedItemCollectionId || true) { var index: Int32 = 0 - if collectionId.namespace == Namespaces.ItemCollection.CloudRecentStickers { + if collectionId.namespace == ChatMediaInputPanelAuxiliaryNamespace.recentGifs.rawValue { + strongSelf.setCurrentPane(.gifs, transition: .animated(duration: 0.25, curve: .spring)) + } else if collectionId.namespace == ChatMediaInputPanelAuxiliaryNamespace.recentStickers.rawValue { + strongSelf.setCurrentPane(.stickers, transition: .animated(duration: 0.25, curve: .spring)) strongSelf.itemCollectionsViewPosition.set(.single(.navigate(index: nil))) } else { + strongSelf.setCurrentPane(.stickers, transition: .animated(duration: 0.25, curve: .spring)) for (id, _, _) in currentView.collectionInfos { if id.namespace == collectionId.namespace { if id == collectionId { @@ -243,7 +279,6 @@ final class ChatMediaInputNode: ChatInputNode { self.addSubnode(self.collectionListPanel) self.addSubnode(self.collectionListSeparator) self.addSubnode(self.listView) - self.addSubnode(self.gridNode) let itemCollectionsView = self.itemCollectionsViewPosition.get() |> distinctUntilChanged @@ -310,7 +345,7 @@ final class ChatMediaInputNode: ChatInputNode { } })) - self.gridNode.visibleItemsUpdated = { [weak self] visibleItems in + self.stickerPane.gridNode.visibleItemsUpdated = { [weak self] visibleItems in if let strongSelf = self { var topVisibleCollectionId: ItemCollectionId? @@ -321,35 +356,7 @@ final class ChatMediaInputNode: ChatInputNode { } if let collectionId = topVisibleCollectionId { if strongSelf.inputNodeInteraction.highlightedItemCollectionId != collectionId { - strongSelf.inputNodeInteraction.highlightedItemCollectionId = collectionId - var ensuredNodeVisible = false - var firstVisibleCollectionId: ItemCollectionId? - strongSelf.listView.forEachItemNode { itemNode in - if let itemNode = itemNode as? ChatMediaInputStickerPackItemNode { - if firstVisibleCollectionId == nil { - firstVisibleCollectionId = itemNode.currentCollectionId - } - itemNode.updateIsHighlighted() - if itemNode.currentCollectionId == collectionId { - strongSelf.listView.ensureItemNodeVisible(itemNode) - ensuredNodeVisible = true - } - } else if let itemNode = itemNode as? ChatMediaInputRecentStickerPacksItemNode { - itemNode.updateIsHighlighted() - if itemNode.currentCollectionId == collectionId { - strongSelf.listView.ensureItemNodeVisible(itemNode) - ensuredNodeVisible = true - } - } - } - if let currentView = strongSelf.currentView, let firstVisibleCollectionId = firstVisibleCollectionId, !ensuredNodeVisible { - let targetIndex = currentView.collectionInfos.index(where: { id, _, _ in return id == collectionId }) - let firstVisibleIndex = currentView.collectionInfos.index(where: { id, _, _ in return id == firstVisibleCollectionId }) - if let targetIndex = targetIndex, let firstVisibleIndex = firstVisibleIndex { - let toRight = targetIndex > firstVisibleIndex - strongSelf.listView.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [], scrollToItem: ListViewScrollToItem(index: targetIndex, position: toRight ? .Bottom : .Top, animated: true, curve: .Default, directionHint: toRight ? .Down : .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil) - } - } + strongSelf.setHighlightedItemCollectionId(collectionId) } } @@ -371,6 +378,15 @@ final class ChatMediaInputNode: ChatInputNode { } } + let longTapRecognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.previewGesture(_:))) + longTapRecognizer.tapActionAtPoint = { [weak self] location in + if let strongSelf = self, let _ = strongSelf.stickerPane.gridNode.itemNodeAtPoint(location) as? ChatMediaInputStickerGridItemNode { + return .waitForHold(timeout: 0.2, acceptTap: false) + } + return .fail + } + self.stickerPane.gridNode.view.addGestureRecognizer(longTapRecognizer) + self.currentStickerPacksCollectionPosition = .initial self.itemCollectionsViewPosition.set(.single(.initial)) } @@ -379,11 +395,84 @@ final class ChatMediaInputNode: ChatInputNode { self.disposable.dispose() } + override func didLoad() { + super.didLoad() + + self.view.disablesInteractiveTransitionGestureRecognizer = true + self.view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:)))) + } + private func heightForWidth(width: CGFloat) -> CGFloat { return defaultPortraitPanelHeight } + private func setCurrentPane(_ pane: ChatMediaInputPane, transition: ContainedViewLayoutTransition) { + if let index = self.paneArrangement.panes.index(of: pane), index != self.paneArrangement.currentIndex { + self.paneArrangement = self.paneArrangement.withIndexTransition(0.0).withCurrentIndex(index) + if let (width, interfaceState) = self.validLayout { + let _ = self.updateLayout(width: width, transition: .animated(duration: 0.25, curve: .spring), interfaceState: interfaceState) + } + switch pane { + case .gifs: + self.setHighlightedItemCollectionId(ItemCollectionId(namespace: ChatMediaInputPanelAuxiliaryNamespace.recentGifs.rawValue, id: 0)) + case .stickers: + if let highlightedStickerCollectionId = self.inputNodeInteraction.highlightedStickerItemCollectionId { + self.setHighlightedItemCollectionId(highlightedStickerCollectionId) + } + } + } + } + + private func setHighlightedItemCollectionId(_ collectionId: ItemCollectionId) { + if collectionId.namespace == ChatMediaInputPanelAuxiliaryNamespace.recentGifs.rawValue { + if self.paneArrangement.panes[self.paneArrangement.currentIndex] == .gifs { + self.inputNodeInteraction.highlightedItemCollectionId = collectionId + } + } else { + self.inputNodeInteraction.highlightedStickerItemCollectionId = collectionId + if self.paneArrangement.panes[self.paneArrangement.currentIndex] == .stickers { + self.inputNodeInteraction.highlightedItemCollectionId = collectionId + } + } + var ensuredNodeVisible = false + var firstVisibleCollectionId: ItemCollectionId? + self.listView.forEachItemNode { itemNode in + if let itemNode = itemNode as? ChatMediaInputStickerPackItemNode { + if firstVisibleCollectionId == nil { + firstVisibleCollectionId = itemNode.currentCollectionId + } + itemNode.updateIsHighlighted() + if itemNode.currentCollectionId == collectionId { + self.listView.ensureItemNodeVisible(itemNode) + ensuredNodeVisible = true + } + } else if let itemNode = itemNode as? ChatMediaInputRecentStickerPacksItemNode { + itemNode.updateIsHighlighted() + if itemNode.currentCollectionId == collectionId { + self.listView.ensureItemNodeVisible(itemNode) + ensuredNodeVisible = true + } + } else if let itemNode = itemNode as? ChatMediaInputRecentGifsItemNode { + itemNode.updateIsHighlighted() + if itemNode.currentCollectionId == collectionId { + self.listView.ensureItemNodeVisible(itemNode) + ensuredNodeVisible = true + } + } + } + + if let currentView = self.currentView, let firstVisibleCollectionId = firstVisibleCollectionId, !ensuredNodeVisible { + let targetIndex = currentView.collectionInfos.index(where: { id, _, _ in return id == collectionId }) + let firstVisibleIndex = currentView.collectionInfos.index(where: { id, _, _ in return id == firstVisibleCollectionId }) + if let targetIndex = targetIndex, let firstVisibleIndex = firstVisibleIndex { + let toRight = targetIndex > firstVisibleIndex + self.listView.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [], scrollToItem: ListViewScrollToItem(index: targetIndex, position: toRight ? .Bottom : .Top, animated: true, curve: .Default, directionHint: toRight ? .Down : .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil) + } + } + } + override func updateLayout(width: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> CGFloat { + self.validLayout = (width, interfaceState) let separatorHeight = UIScreenPixel let panelHeight = self.heightForWidth(width: width) @@ -419,9 +508,74 @@ final class ChatMediaInputNode: ChatInputNode { self.listView.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) - self.gridNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 41.0), size: CGSize(width: width, height: panelHeight - 41.0)) + var visiblePanes: [(ChatMediaInputPane, CGFloat)] = [] - self.gridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: CGSize(width: width, height: panelHeight - 41.0), insets: UIEdgeInsets(), preloadSize: 300.0, itemSize: CGSize(width: 75.0, height: 75.0)), transition: .immediate), stationaryItems: .all, updateFirstIndexInSectionOffset: nil), completion: { _ in }) + var paneIndex = 0 + for pane in self.paneArrangement.panes { + let paneOrigin = CGFloat(paneIndex - self.paneArrangement.currentIndex) * width - self.paneArrangement.indexTransition * width + if paneOrigin.isLess(than: width) && CGFloat(0.0).isLess(than: (paneOrigin + width)) { + visiblePanes.append((pane, paneOrigin)) + } + paneIndex += 1 + } + + for (pane, paneOrigin) in visiblePanes { + switch pane { + case .gifs: + if self.gifPane.supernode == nil { + self.addSubnode(self.gifPane) + self.gifPane.frame = CGRect(origin: CGPoint(x: -width, y: 41.0), size: CGSize(width: width, height: panelHeight - 41.0)) + } + self.gifPane.layer.removeAnimation(forKey: "position") + transition.updateFrame(node: self.gifPane, frame: CGRect(origin: CGPoint(x: paneOrigin, y: 41.0), size: CGSize(width: width, height: panelHeight - 41.0))) + case .stickers: + if self.stickerPane.supernode == nil { + self.addSubnode(self.stickerPane) + self.stickerPane.frame = CGRect(origin: CGPoint(x: width, y: 41.0), size: CGSize(width: width, height: panelHeight - 41.0)) + } + self.stickerPane.layer.removeAnimation(forKey: "position") + transition.updateFrame(node: self.stickerPane, frame: CGRect(origin: CGPoint(x: paneOrigin, y: 41.0), size: CGSize(width: width, height: panelHeight - 41.0))) + } + } + + self.gifPane.updateLayout(size: CGSize(width: width, height: panelHeight - 41.0), transition: transition) + self.stickerPane.updateLayout(size: CGSize(width: width, height: panelHeight - 41.0), transition: transition) + + if self.gifPane.supernode != nil { + if !visiblePanes.contains(where: { $0.0 == .gifs }) { + if case .animated = transition { + var toLeft = false + if let index = self.paneArrangement.panes.index(of: .gifs), index < self.paneArrangement.currentIndex { + toLeft = true + } + transition.animatePosition(node: self.gifPane, to: CGPoint(x: (toLeft ? -width : width) + width / 2.0, y: self.gifPane.layer.position.y), removeOnCompletion: false, completion: { [weak self] value in + if let strongSelf = self, value { + strongSelf.gifPane.removeFromSupernode() + } + }) + } else { + self.gifPane.removeFromSupernode() + } + } + } + + if self.stickerPane.supernode != nil { + if !visiblePanes.contains(where: { $0.0 == .stickers }) { + if case .animated = transition { + var toLeft = false + if let index = self.paneArrangement.panes.index(of: .stickers), index < self.paneArrangement.currentIndex { + toLeft = true + } + transition.animatePosition(node: self.stickerPane, to: CGPoint(x: (toLeft ? -width : width) + width / 2.0, y: self.stickerPane.layer.position.y), removeOnCompletion: false, completion: { [weak self] value in + if let strongSelf = self, value { + strongSelf.stickerPane.removeFromSupernode() + } + }) + } else { + self.stickerPane.removeFromSupernode() + } + } + } return panelHeight } @@ -439,6 +593,99 @@ final class ChatMediaInputNode: ChatInputNode { } private func enqueueGridTransition(_ transition: ChatMediaInputGridTransition, firstTime: Bool) { - self.gridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: transition.scrollToItem, updateLayout: nil, stationaryItems: transition.stationaryItems, updateFirstIndexInSectionOffset: transition.updateFirstIndexInSectionOffset), completion: { _ in }) + self.stickerPane.gridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: transition.scrollToItem, updateLayout: nil, stationaryItems: transition.stationaryItems, updateFirstIndexInSectionOffset: transition.updateFirstIndexInSectionOffset), completion: { _ in }) + } + + @objc func previewGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { + switch recognizer.state { + case .began: + if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation, case .hold = gesture { + if let itemNode = self.stickerPane.gridNode.itemNodeAtPoint(location) as? ChatMediaInputStickerGridItemNode { + self.updatePreviewingItem(item: itemNode.stickerPackItem, animated: true) + } + } + case .ended, .cancelled: + self.updatePreviewingItem(item: nil, animated: true) + case .changed: + if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation, case .hold = gesture, let itemNode = self.stickerPane.gridNode.itemNodeAtPoint(location) as? ChatMediaInputStickerGridItemNode { + self.updatePreviewingItem(item: itemNode.stickerPackItem, animated: true) + } + default: + break + } + } + + private func updatePreviewingItem(item: StickerPackItem?, animated: Bool) { + if self.inputNodeInteraction.previewedStickerPackItem != item { + self.inputNodeInteraction.previewedStickerPackItem = item + + self.stickerPane.gridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ChatMediaInputStickerGridItemNode { + itemNode.updatePreviewing(animated: animated) + } + } + + if let item = item { + if let stickerPreviewController = self.stickerPreviewController { + stickerPreviewController.updateItem(item) + } else { + let stickerPreviewController = StickerPreviewController(account: self.account, item: item) + self.stickerPreviewController = stickerPreviewController + self.controllerInteraction.presentController(stickerPreviewController, StickerPreviewControllerPresentationArguments(transitionNode: { [weak self] item in + if let strongSelf = self { + var result: ASDisplayNode? + strongSelf.stickerPane.gridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ChatMediaInputStickerGridItemNode, itemNode.stickerPackItem == item { + result = itemNode.transitionNode() + } + } + return result + } + return nil + })) + } + } else if let stickerPreviewController = self.stickerPreviewController { + stickerPreviewController.dismiss() + self.stickerPreviewController = nil + } + } + } + + @objc func panGesture(_ recognizer: UIPanGestureRecognizer) { + switch recognizer.state { + case .began: + break + case .changed: + if let (width, interfaceState) = self.validLayout { + let translationX = -recognizer.translation(in: self.view).x + var indexTransition = translationX / width + if self.paneArrangement.currentIndex == 0 { + indexTransition = max(0.0, indexTransition) + } else if self.paneArrangement.currentIndex == self.paneArrangement.panes.count - 1 { + indexTransition = min(0.0, indexTransition) + } + self.paneArrangement = self.paneArrangement.withIndexTransition(indexTransition) + let _ = self.updateLayout(width: width, transition: .immediate, interfaceState: interfaceState) + } + case .ended: + if let (width, _) = self.validLayout { + var updatedIndex = self.paneArrangement.currentIndex + if abs(self.paneArrangement.indexTransition * width) > 30.0 { + if self.paneArrangement.indexTransition < 0.0 { + updatedIndex = max(0, self.paneArrangement.currentIndex - 1) + } else { + updatedIndex = min(self.paneArrangement.panes.count - 1, self.paneArrangement.currentIndex + 1) + } + } + self.setCurrentPane(self.paneArrangement.panes[updatedIndex], transition: .animated(duration: 0.25, curve: .spring)) + } + case .cancelled: + if let (width, interfaceState) = self.validLayout { + self.paneArrangement = self.paneArrangement.withIndexTransition(0.0) + let _ = self.updateLayout(width: width, transition: .animated(duration: 0.25, curve: .spring), interfaceState: interfaceState) + } + default: + break + } } } diff --git a/TelegramUI/ChatMediaInputPanelEntries.swift b/TelegramUI/ChatMediaInputPanelEntries.swift index d074ac830e..5664245d7a 100644 --- a/TelegramUI/ChatMediaInputPanelEntries.swift +++ b/TelegramUI/ChatMediaInputPanelEntries.swift @@ -3,12 +3,25 @@ import TelegramCore import SwiftSignalKit import Display +enum ChatMediaInputPanelAuxiliaryNamespace: Int32 { + case recentGifs = 3 + case recentStickers = 2 + case trending = 4 +} + enum ChatMediaInputPanelEntryStableId: Hashable { + case recentGifs case recentPacks case stickerPack(Int64) static func ==(lhs: ChatMediaInputPanelEntryStableId, rhs: ChatMediaInputPanelEntryStableId) -> Bool { switch lhs { + case .recentGifs: + if case .recentGifs = rhs { + return true + } else { + return false + } case .recentPacks: if case .recentPacks = rhs { return true @@ -26,8 +39,10 @@ enum ChatMediaInputPanelEntryStableId: Hashable { var hashValue: Int { switch self { - case .recentPacks: + case .recentGifs: return 0 + case .recentPacks: + return 1 case let .stickerPack(id): return id.hashValue } @@ -35,11 +50,14 @@ enum ChatMediaInputPanelEntryStableId: Hashable { } enum ChatMediaInputPanelEntry: Comparable, Identifiable { + case recentGifs case recentPacks case stickerPack(index: Int, info: StickerPackCollectionInfo, topItem: StickerPackItem?) var stableId: ChatMediaInputPanelEntryStableId { switch self { + case .recentGifs: + return .recentGifs case .recentPacks: return .recentPacks case let .stickerPack(_, info, _): @@ -49,6 +67,12 @@ enum ChatMediaInputPanelEntry: Comparable, Identifiable { static func ==(lhs: ChatMediaInputPanelEntry, rhs: ChatMediaInputPanelEntry) -> Bool { switch lhs { + case .recentGifs: + if case .recentGifs = rhs { + return true + } else { + return false + } case .recentPacks: if case .recentPacks = rhs { return true @@ -66,11 +90,24 @@ enum ChatMediaInputPanelEntry: Comparable, Identifiable { static func <(lhs: ChatMediaInputPanelEntry, rhs: ChatMediaInputPanelEntry) -> Bool { switch lhs { - case .recentPacks: + case .recentGifs: + switch rhs { + case .recentGifs: + return false + default: + return true + } return true + case .recentPacks: + switch rhs { + case .recentGifs, recentPacks: + return false + default: + return true + } case let .stickerPack(lhsIndex, lhsInfo, _): switch rhs { - case .recentPacks: + case .recentGifs, .recentPacks: return false case let .stickerPack(rhsIndex, rhsInfo, _): if lhsIndex == rhsIndex { @@ -84,9 +121,14 @@ enum ChatMediaInputPanelEntry: Comparable, Identifiable { func item(account: Account, inputNodeInteraction: ChatMediaInputNodeInteraction) -> ListViewItem { switch self { + case .recentGifs: + return ChatMediaInputRecentGifsItem(inputNodeInteraction: inputNodeInteraction, selected: { + let collectionId = ItemCollectionId(namespace: ChatMediaInputPanelAuxiliaryNamespace.recentGifs.rawValue, id: 0) + inputNodeInteraction.navigateToCollectionId(collectionId) + }) case .recentPacks: return ChatMediaInputRecentStickerPacksItem(inputNodeInteraction: inputNodeInteraction, selected: { - let collectionId = ItemCollectionId(namespace: Namespaces.ItemCollection.CloudRecentStickers, id: 0) + let collectionId = ItemCollectionId(namespace: ChatMediaInputPanelAuxiliaryNamespace.recentStickers.rawValue, id: 0) inputNodeInteraction.navigateToCollectionId(collectionId) }) case let .stickerPack(index, info, topItem): diff --git a/TelegramUI/ChatMediaInputRecentGifsItem.swift b/TelegramUI/ChatMediaInputRecentGifsItem.swift new file mode 100644 index 0000000000..09aac21abb --- /dev/null +++ b/TelegramUI/ChatMediaInputRecentGifsItem.swift @@ -0,0 +1,118 @@ +import Foundation +import Display +import AsyncDisplayKit +import TelegramCore +import SwiftSignalKit +import Postbox + +private let iconImage = generateImage(CGSize(width: 26.0, height: 26.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setStrokeColor(UIColor(0x9099A2).cgColor) + context.setLineWidth(2.0) + context.setLineCap(.round) + let diameter: CGFloat = 22.0 + context.strokeEllipse(in: CGRect(origin: CGPoint(x: floor((size.width - diameter) / 2.0), y: floor((size.width - diameter) / 2.0)), size: CGSize(width: diameter, height: diameter))) + context.setFillColor(UIColor(0x9099A2).cgColor) + UIGraphicsPushContext(context) + + context.setTextDrawingMode(.stroke) + context.setLineWidth(0.65) + + ("GIF" as NSString).draw(in: CGRect(origin: CGPoint(x: 6.0, y: 8.0), size: size), withAttributes: [NSFontAttributeName: Font.regular(8.0), NSForegroundColorAttributeName: UIColor(0x9099A2)]) + + context.setTextDrawingMode(.fill) + context.setLineWidth(0.8) + + ("GIF" as NSString).draw(in: CGRect(origin: CGPoint(x: 6.0, y: 8.0), size: size), withAttributes: [NSFontAttributeName: Font.regular(8.0), NSForegroundColorAttributeName: UIColor(0x9099A2)]) + //("GIF" as NSString).draw(in: CGRect(origin: CGPoint(x: 6.0, y: 8.0), size: size), withAttributes: [NSFontAttributeName: Font.bold(8.0), NSForegroundColorAttributeName: UIColor(0x9099A2)]) + UIGraphicsPopContext() +}) + +final class ChatMediaInputRecentGifsItem: ListViewItem { + let inputNodeInteraction: ChatMediaInputNodeInteraction + let selectedItem: () -> Void + + var selectable: Bool { + return true + } + + init(inputNodeInteraction: ChatMediaInputNodeInteraction, selected: @escaping () -> Void) { + self.inputNodeInteraction = inputNodeInteraction + self.selectedItem = selected + } + + func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { + async { + let node = ChatMediaInputRecentGifsItemNode() + node.contentSize = CGSize(width: 41.0, height: 41.0) + node.insets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0) + node.inputNodeInteraction = self.inputNodeInteraction + completion(node, { + return (nil, {}) + }) + } + } + + public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { + completion(ListViewItemNodeLayout(contentSize: node.contentSize, insets: node.insets), { + }) + } + + func selected(listView: ListView) { + self.selectedItem() + } +} + +private let boundingSize = CGSize(width: 41.0, height: 41.0) +private let boundingImageSize = CGSize(width: 30.0, height: 30.0) +private let highlightSize = CGSize(width: 35.0, height: 35.0) +private let verticalOffset: CGFloat = 3.0 + UIScreenPixel + +private let highlightBackground = generateStretchableFilledCircleImage(radius: 9.0, color: UIColor(0x9099A2, 0.2)) + +final class ChatMediaInputRecentGifsItemNode: ListViewItemNode { + private let imageNode: ASImageNode + private let highlightNode: ASImageNode + + var currentCollectionId: ItemCollectionId? + var inputNodeInteraction: ChatMediaInputNodeInteraction? + + init() { + self.highlightNode = ASImageNode() + self.highlightNode.isLayerBacked = true + self.highlightNode.image = highlightBackground + self.highlightNode.isHidden = true + + self.imageNode = ASImageNode() + self.imageNode.isLayerBacked = true + + self.highlightNode.frame = CGRect(origin: CGPoint(x: floor((boundingSize.width - highlightSize.width) / 2.0) + verticalOffset, y: floor((boundingSize.height - highlightSize.height) / 2.0)), size: highlightSize) + + self.imageNode.image = iconImage + self.imageNode.transform = CATransform3DMakeRotation(CGFloat.pi / 2.0, 0.0, 0.0, 1.0) + + super.init(layerBacked: false, dynamicBounce: false) + + self.addSubnode(self.highlightNode) + self.addSubnode(self.imageNode) + + self.currentCollectionId = ItemCollectionId(namespace: ChatMediaInputPanelAuxiliaryNamespace.recentGifs.rawValue, id: 0) + + let imageSize = CGSize(width: 26.0, height: 26.0) + self.imageNode.frame = CGRect(origin: CGPoint(x: floor((boundingSize.width - imageSize.width) / 2.0) + verticalOffset, y: floor((boundingSize.height - imageSize.height) / 2.0) + UIScreenPixel), size: imageSize) + } + + deinit { + } + + func updateStickerPackItem(account: Account, item: StickerPackItem?, collectionId: ItemCollectionId) { + self.currentCollectionId = collectionId + self.updateIsHighlighted() + } + + func updateIsHighlighted() { + if let currentCollectionId = self.currentCollectionId, let inputNodeInteraction = self.inputNodeInteraction { + self.highlightNode.isHidden = inputNodeInteraction.highlightedItemCollectionId != currentCollectionId + } + } +} diff --git a/TelegramUI/ChatMediaInputRecentStickerPacksItem.swift b/TelegramUI/ChatMediaInputRecentStickerPacksItem.swift index daba9e7dba..f2d09c9e34 100644 --- a/TelegramUI/ChatMediaInputRecentStickerPacksItem.swift +++ b/TelegramUI/ChatMediaInputRecentStickerPacksItem.swift @@ -80,14 +80,14 @@ final class ChatMediaInputRecentStickerPacksItemNode: ListViewItemNode { self.highlightNode.frame = CGRect(origin: CGPoint(x: floor((boundingSize.width - highlightSize.width) / 2.0) + verticalOffset, y: floor((boundingSize.height - highlightSize.height) / 2.0)), size: highlightSize) self.imageNode.image = iconImage - self.imageNode.transform = CATransform3DMakeRotation(CGFloat(M_PI / 2.0), 0.0, 0.0, 1.0) + self.imageNode.transform = CATransform3DMakeRotation(CGFloat.pi / 2.0, 0.0, 0.0, 1.0) super.init(layerBacked: false, dynamicBounce: false) self.addSubnode(self.highlightNode) self.addSubnode(self.imageNode) - self.currentCollectionId = ItemCollectionId(namespace: Namespaces.ItemCollection.CloudRecentStickers, id: 0) + self.currentCollectionId = ItemCollectionId(namespace: ChatMediaInputPanelAuxiliaryNamespace.recentStickers.rawValue, id: 0) let imageSize = CGSize(width: 26.0, height: 26.0) self.imageNode.frame = CGRect(origin: CGPoint(x: floor((boundingSize.width - imageSize.width) / 2.0) + verticalOffset, y: floor((boundingSize.height - imageSize.height) / 2.0) + UIScreenPixel), size: imageSize) diff --git a/TelegramUI/ChatMediaInputStickerGridItem.swift b/TelegramUI/ChatMediaInputStickerGridItem.swift index 0b3b7668cd..089f67db9c 100644 --- a/TelegramUI/ChatMediaInputStickerGridItem.swift +++ b/TelegramUI/ChatMediaInputStickerGridItem.swift @@ -106,10 +106,16 @@ final class ChatMediaInputStickerGridItemNode: GridItemNode { private let stickerFetchedDisposable = MetaDisposable() + var currentIsPreviewing = false + var interfaceInteraction: ChatControllerInteraction? var inputNodeInteraction: ChatMediaInputNodeInteraction? var selected: (() -> Void)? + var stickerPackItem: StickerPackItem? { + return self.currentState?.1 + } + override init() { self.imageNode = TransformImageNode() @@ -156,20 +162,35 @@ final class ChatMediaInputStickerGridItemNode: GridItemNode { } } - /*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 transitionNode() -> ASDisplayNode? { + return self.imageNode + } + + func updatePreviewing(animated: Bool) { + var isPreviewing = false + if let (_, item, _) = self.currentState, let interaction = self.inputNodeInteraction { + isPreviewing = interaction.previewedStickerPackItem == item + } + if self.currentIsPreviewing != isPreviewing { + self.currentIsPreviewing = isPreviewing + + if isPreviewing { + self.layer.sublayerTransform = CATransform3DMakeScale(0.8, 0.8, 1.0) + if animated { + self.layer.animateSpring(from: 1.0 as NSNumber, to: 0.8 as NSNumber, keyPath: "sublayerTransform.scale", duration: 0.4) + } + } else { + self.layer.sublayerTransform = CATransform3DIdentity + if animated { + self.layer.animateSpring(from: 0.8 as NSNumber, to: 1.0 as NSNumber, keyPath: "sublayerTransform.scale", duration: 0.5) + } + } + } } } diff --git a/TelegramUI/ChatMediaInputStickerPane.swift b/TelegramUI/ChatMediaInputStickerPane.swift new file mode 100644 index 0000000000..95cd0123a9 --- /dev/null +++ b/TelegramUI/ChatMediaInputStickerPane.swift @@ -0,0 +1,24 @@ +import Foundation +import AsyncDisplayKit +import Display +import Postbox +import TelegramCore +import SwiftSignalKit + +final class ChatMediaInputStickerPane: ASDisplayNode { + let gridNode: GridNode + + override init() { + self.gridNode = GridNode() + + super.init() + + self.addSubnode(self.gridNode) + } + + func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { + self.gridNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height)) + + self.gridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: size, insets: UIEdgeInsets(), preloadSize: 300.0, type: .fixed(itemSize: CGSize(width: 75.0, height: 75.0))), transition: .immediate), stationaryItems: .all, updateFirstIndexInSectionOffset: nil), completion: { _ in }) + } +} diff --git a/TelegramUI/ChatMediaInputTrendingItem.swift b/TelegramUI/ChatMediaInputTrendingItem.swift new file mode 100644 index 0000000000..1040220b20 --- /dev/null +++ b/TelegramUI/ChatMediaInputTrendingItem.swift @@ -0,0 +1,111 @@ +import Foundation +import Display +import AsyncDisplayKit +import TelegramCore +import SwiftSignalKit +import Postbox + +private let iconImage = generateImage(CGSize(width: 26.0, height: 26.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setStrokeColor(UIColor(0x9099A2).cgColor) + context.setLineWidth(2.0) + context.setLineCap(.round) + let diameter: CGFloat = 22.0 + context.strokeEllipse(in: CGRect(origin: CGPoint(x: floor((size.width - diameter) / 2.0), y: floor((size.width - diameter) / 2.0)), size: CGSize(width: diameter, height: diameter))) + context.setFillColor(UIColor(0x9099A2).cgColor) + UIGraphicsPushContext(context) + + context.setTextDrawingMode(.stroke) + context.setLineWidth(0.65) + + UIGraphicsPopContext() +}) + +final class ChatMediaInputTrendingItem: ListViewItem { + let inputNodeInteraction: ChatMediaInputNodeInteraction + let selectedItem: () -> Void + + var selectable: Bool { + return true + } + + init(inputNodeInteraction: ChatMediaInputNodeInteraction, selected: @escaping () -> Void) { + self.inputNodeInteraction = inputNodeInteraction + self.selectedItem = selected + } + + func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { + async { + let node = ChatMediaInputTrendingItemNode() + node.contentSize = CGSize(width: 41.0, height: 41.0) + node.insets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0) + node.inputNodeInteraction = self.inputNodeInteraction + completion(node, { + return (nil, {}) + }) + } + } + + public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { + completion(ListViewItemNodeLayout(contentSize: node.contentSize, insets: node.insets), { + }) + } + + func selected(listView: ListView) { + self.selectedItem() + } +} + +private let boundingSize = CGSize(width: 41.0, height: 41.0) +private let boundingImageSize = CGSize(width: 30.0, height: 30.0) +private let highlightSize = CGSize(width: 35.0, height: 35.0) +private let verticalOffset: CGFloat = 3.0 + UIScreenPixel + +private let highlightBackground = generateStretchableFilledCircleImage(radius: 9.0, color: UIColor(0x9099A2, 0.2)) + +final class ChatMediaInputTrendingItemNode: ListViewItemNode { + private let imageNode: ASImageNode + private let highlightNode: ASImageNode + + var currentCollectionId: ItemCollectionId? + var inputNodeInteraction: ChatMediaInputNodeInteraction? + + init() { + self.highlightNode = ASImageNode() + self.highlightNode.isLayerBacked = true + self.highlightNode.image = highlightBackground + self.highlightNode.isHidden = true + + self.imageNode = ASImageNode() + self.imageNode.isLayerBacked = true + + self.highlightNode.frame = CGRect(origin: CGPoint(x: floor((boundingSize.width - highlightSize.width) / 2.0) + verticalOffset, y: floor((boundingSize.height - highlightSize.height) / 2.0)), size: highlightSize) + + self.imageNode.image = iconImage + self.imageNode.transform = CATransform3DMakeRotation(CGFloat.pi / 2.0, 0.0, 0.0, 1.0) + + super.init(layerBacked: false, dynamicBounce: false) + + self.addSubnode(self.highlightNode) + self.addSubnode(self.imageNode) + + self.currentCollectionId = ItemCollectionId(namespace: ChatMediaInputPanelAuxiliaryNamespace.trending.rawValue, id: 0) + + let imageSize = CGSize(width: 26.0, height: 26.0) + self.imageNode.frame = CGRect(origin: CGPoint(x: floor((boundingSize.width - imageSize.width) / 2.0) + verticalOffset, y: floor((boundingSize.height - imageSize.height) / 2.0) + UIScreenPixel), size: imageSize) + } + + deinit { + } + + func updateStickerPackItem(account: Account, item: StickerPackItem?, collectionId: ItemCollectionId) { + self.currentCollectionId = collectionId + self.updateIsHighlighted() + } + + func updateIsHighlighted() { + if let currentCollectionId = self.currentCollectionId, let inputNodeInteraction = self.inputNodeInteraction { + self.highlightNode.isHidden = inputNodeInteraction.highlightedItemCollectionId != currentCollectionId + } + } +} diff --git a/TelegramUI/ChatMediaInputTrendingPane.swift b/TelegramUI/ChatMediaInputTrendingPane.swift new file mode 100644 index 0000000000..9ae84f8e62 --- /dev/null +++ b/TelegramUI/ChatMediaInputTrendingPane.swift @@ -0,0 +1,22 @@ +import Foundation +import AsyncDisplayKit +import Display +import Postbox +import TelegramCore +import SwiftSignalKit + +final class ChatMediaInputTrendingPane: ASDisplayNode { + private let account: Account + + private let listNode: ListView + + init(account: Account) { + self.account = account + + self.listNode = ListView() + + super.init() + + self.addSubnode(self.listNode) + } +} diff --git a/TelegramUI/ChatMessageBubbleContentNode.swift b/TelegramUI/ChatMessageBubbleContentNode.swift index b58392c884..d9dfdefc63 100644 --- a/TelegramUI/ChatMessageBubbleContentNode.swift +++ b/TelegramUI/ChatMessageBubbleContentNode.swift @@ -47,6 +47,8 @@ class ChatMessageBubbleContentNode: ASDisplayNode { var controllerInteraction: ChatControllerInteraction? + var visibility: ListViewItemNodeVisibility = .none + required override init() { //super.init(layerBacked: false) super.init() diff --git a/TelegramUI/ChatMessageBubbleImages.swift b/TelegramUI/ChatMessageBubbleImages.swift index 685712c7ee..019e4b761c 100644 --- a/TelegramUI/ChatMessageBubbleImages.swift +++ b/TelegramUI/ChatMessageBubbleImages.swift @@ -131,3 +131,16 @@ func messageBubbleActionButtonImage(color: UIColor, position: MessageBubbleActio } })!.stretchableImage(withLeftCapWidth: Int(size.width / 2.0), topCapHeight: Int(size.height / 2.0)) } + +func generateInstantVideoBackground(incoming: Bool, highlighted: Bool = false) -> UIImage? { + return generateImage(CGSize(width: 212.0, height: 212.0), rotatedContext: { size, context in + let lineWidth: CGFloat = 0.5 + + context.clear(CGRect(origin: CGPoint(), size: size)) + + context.setFillColor((incoming ? (highlighted ? incomingStrokeColor : incomingStrokeColor) : (highlighted ? outgoingStrokeColor : outgoingStrokeColor)).cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + context.setFillColor((incoming ? (highlighted ? incomingFillHighlightedColor : incomingFillColor) : (highlighted ? outgoingFillHighlightedColor : outgoingFillColor)).cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: lineWidth, y: lineWidth), size: CGSize(width: size.width - lineWidth * 2.0, height: size.height - lineWidth * 2.0))) + }) +} diff --git a/TelegramUI/ChatMessageBubbleItemNode.swift b/TelegramUI/ChatMessageBubbleItemNode.swift index 5c8fd6d230..751a7e1866 100644 --- a/TelegramUI/ChatMessageBubbleItemNode.swift +++ b/TelegramUI/ChatMessageBubbleItemNode.swift @@ -54,6 +54,9 @@ private let chatMessagePeerIdColors: [UIColor] = [ UIColor(0x895dd5) ] +private let shareButtonBackgroundImage = generateFilledCircleImage(diameter: 29.0, color: UIColor(0x748391, 0.45)) +private let shareButtonImage = UIImage(bundleImageName: "Chat/Message/ShareIcon")?.precomposed() + class ChatMessageBubbleItemNode: ChatMessageItemView { private let backgroundNode: ChatMessageBackground private var transitionClippingNode: ASDisplayNode? @@ -67,6 +70,8 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { private var contentNodes: [ChatMessageBubbleContentNode] = [] private var actionButtonsNode: ChatMessageActionButtonsNode? + private var shareButtonNode: HighlightableButtonNode? + private var messageId: MessageId? private var messageStableId: UInt32? private var backgroundType: ChatMessageBackgroundType? @@ -74,6 +79,16 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { private var backgroundFrameTransition: (CGRect, CGRect)? + override var visibility: ListViewItemNodeVisibility { + didSet { + if self.visibility != oldValue { + for contentNode in self.contentNodes { + contentNode.visibility = self.visibility + } + } + } + } + required init() { self.backgroundNode = ChatMessageBackground() @@ -94,20 +109,12 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { node.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } - - for contentNode in self.contentNodes { - //contentNode.animateInsertion(currentTimestamp, duration: duration) - } } override func animateRemoved(_ currentTimestamp: Double, duration: Double) { super.animateRemoved(currentTimestamp, duration: duration) self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) - - for contentNode in self.contentNodes { - //contentNode.animateRemoved(currentTimestamp, duration: duration) - } } override func animateAdded(_ currentTimestamp: Double, duration: Double) { @@ -130,6 +137,10 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) recognizer.tapActionAtPoint = { [weak self] point in if let strongSelf = self { + if let shareButtonNode = strongSelf.shareButtonNode, shareButtonNode.frame.contains(point) { + return .fail + } + if let avatarNode = strongSelf.accessoryItemNode as? ChatMessageAvatarAccessoryItemNode, avatarNode.frame.contains(point) { return .waitForSingleTap } @@ -157,7 +168,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { case .url, .peerMention, .textMention, .botCommand, .hashtag, .instantPage: return .waitForSingleTap case .holdToPreviewSecretMedia: - return .waitForHold + return .waitForHold(timeout: 0.12, acceptTap: false) } } } @@ -178,6 +189,8 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { let replyInfoLayout = ChatMessageReplyInfoNode.asyncLayout(self.replyInfoNode) let actionButtonsLayout = ChatMessageActionButtonsNode.asyncLayout(self.actionButtonsNode) + var currentShareButtonNode = self.shareButtonNode + let layoutConstants = self.layoutConstants return { item, width, mergedTop, mergedBottom, dateHeaderAtBottom in @@ -425,6 +438,27 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { layoutInsets.top += layoutConstants.timestampHeaderHeight } + var needShareButton = false + if let peer = item.message.peers[item.message.id.peerId] { + if let channel = peer as? TelegramChannel { + if case .broadcast = channel.info { + needShareButton = true + } + } + } + + var updatedShareButtonNode: HighlightableButtonNode? + if needShareButton { + if currentShareButtonNode != nil { + updatedShareButtonNode = currentShareButtonNode + } else { + let buttonNode = HighlightableButtonNode() + buttonNode.setBackgroundImage(shareButtonBackgroundImage, for: [.normal]) + buttonNode.setImage(shareButtonImage, for: [.normal]) + updatedShareButtonNode = buttonNode + } + } + let layout = ListViewItemNodeLayout(contentSize: layoutSize, insets: layoutInsets) return (layout, { [weak self] animation in @@ -510,6 +544,8 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { updatedContentNodes.append(contentNode) strongSelf.addSubnode(contentNode) contentNode.controllerInteraction = strongSelf.controllerInteraction + + contentNode.visibility = strongSelf.visibility } } @@ -554,17 +590,38 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { contentNodeOrigin.y += size.height } + if let updatedShareButtonNode = updatedShareButtonNode { + if updatedShareButtonNode !== strongSelf.shareButtonNode { + if let shareButtonNode = strongSelf.shareButtonNode { + shareButtonNode.removeFromSupernode() + } + strongSelf.shareButtonNode = updatedShareButtonNode + strongSelf.addSubnode(updatedShareButtonNode) + updatedShareButtonNode.addTarget(strongSelf, action: #selector(strongSelf.shareButtonPressed), forControlEvents: .touchUpInside) + } + } else if let shareButtonNode = strongSelf.shareButtonNode { + shareButtonNode.removeFromSupernode() + strongSelf.shareButtonNode = nil + } + if case .System = animation { if !strongSelf.backgroundNode.frame.equalTo(backgroundFrame) { strongSelf.backgroundFrameTransition = (strongSelf.backgroundNode.frame, backgroundFrame) strongSelf.enableTransitionClippingNode() } + if let shareButtonNode = strongSelf.shareButtonNode { + let currentBackgroundFrame = strongSelf.backgroundNode.frame + shareButtonNode.frame = CGRect(origin: CGPoint(x: currentBackgroundFrame.maxX + 8.0, y: currentBackgroundFrame.maxY - 30.0), size: CGSize(width: 29.0, height: 29.0)) + } } else { if let _ = strongSelf.backgroundFrameTransition { strongSelf.animateFrameTransition(1.0, backgroundFrame.size.height) strongSelf.backgroundFrameTransition = nil } strongSelf.backgroundNode.frame = backgroundFrame + if let shareButtonNode = strongSelf.shareButtonNode { + shareButtonNode.frame = CGRect(origin: CGPoint(x: backgroundFrame.maxX + 8.0, y: backgroundFrame.maxY - 30.0), size: CGSize(width: 29.0, height: 29.0)) + } strongSelf.disableTransitionClippingNode() } let offset: CGFloat = incoming ? 42.0 : 0.0 @@ -664,6 +721,10 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { let backgroundFrame = CGRect.interpolator()(backgroundFrameTransition.0, backgroundFrameTransition.1, progress) as! CGRect self.backgroundNode.frame = backgroundFrame + if let shareButtonNode = self.shareButtonNode { + shareButtonNode.frame = CGRect(origin: CGPoint(x: backgroundFrame.maxX + 8.0, y: backgroundFrame.maxY - 30.0), size: CGSize(width: 29.0, height: 29.0)) + } + if let transitionClippingNode = self.transitionClippingNode { var fixedBackgroundFrame = backgroundFrame fixedBackgroundFrame = fixedBackgroundFrame.insetBy(dx: 0.0, dy: 1.0) @@ -802,6 +863,10 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if let shareButtonNode = self.shareButtonNode, shareButtonNode.frame.contains(point) { + return shareButtonNode.view + } + if let avatarNode = self.accessoryItemNode as? ChatMessageAvatarAccessoryItemNode, avatarNode.frame.contains(point) { return self.view } @@ -857,7 +922,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { let offset: CGFloat = incoming ? 42.0 : 0.0 if let selectionNode = self.selectionNode { - selectionNode.updateSelected(selected, animated: false) + selectionNode.updateSelected(selected, animated: animated) 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 { @@ -923,7 +988,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { self.backgroundNode.setType(type: backgroundType, highlighted: false) if let updatedContents = self.backgroundNode.layer.contents { - self.backgroundNode.layer.animate(from: previousContents as AnyObject, to: updatedContents as AnyObject, keyPath: "contents", timingFunction: kCAMediaTimingFunctionEaseInEaseOut, duration: 0.3) + self.backgroundNode.layer.animate(from: previousContents as AnyObject, to: updatedContents as AnyObject, keyPath: "contents", timingFunction: kCAMediaTimingFunctionEaseInEaseOut, duration: 0.42) } } else { self.backgroundNode.setType(type: backgroundType, highlighted: false) @@ -970,7 +1035,15 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { if let botPeer = botPeer, let addressName = botPeer.addressName { controllerInteraction.openPeer(peerId, .chat(textInputState: ChatTextInputState(inputText: "@\(addressName) \(query)")), nil) } + case .payment: + break } } } + + @objc func shareButtonPressed() { + if let item = self.item, let controllerInteraction = self.controllerInteraction { + controllerInteraction.openMessageShareMenu(item.message.id) + } + } } diff --git a/TelegramUI/ChatMessageInstantVideoItemNode.swift b/TelegramUI/ChatMessageInstantVideoItemNode.swift new file mode 100644 index 0000000000..5a96fdbdf5 --- /dev/null +++ b/TelegramUI/ChatMessageInstantVideoItemNode.swift @@ -0,0 +1,269 @@ +import Foundation +import AsyncDisplayKit +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private let backgroundImage = generateInstantVideoBackground(incoming: true) + +class ChatMessageInstantVideoItemNode: ChatMessageItemView { + let backgroundNode: ASImageNode + let videoNode: ManagedVideoNode + 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.backgroundNode = ASImageNode() + self.backgroundNode.displaysAsynchronously = false + self.backgroundNode.displayWithoutProcessing = true + + self.videoNode = ManagedVideoNode() + + super.init(layerBacked: false) + + self.backgroundNode.image = backgroundImage + + self.addSubnode(self.backgroundNode) + self.addSubnode(self.videoNode) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + 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 asyncLayout() -> (_ item: ChatMessageItem, _ width: CGFloat, _ mergedTop: Bool, _ mergedBottom: Bool, _ dateHeaderAtBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { + let displaySize = CGSize(width: 210.0, height: 210.0) + let previousFile = self.telegramFile + let layoutConstants = self.layoutConstants + + let makeReplyInfoLayout = ChatMessageReplyInfoNode.asyncLayout(self.replyInfoNode) + let currentReplyBackgroundNode = self.replyBackgroundNode + + return { item, width, mergedTop, mergedBottom, dateHeaderAtBottom in + let incoming = item.message.effectivelyIncoming + let imageSize = displaySize + + var updatedFile: TelegramMediaFile? + var updatedMedia = false + for media in item.message.media { + if let file = media as? TelegramMediaFile { + updatedFile = file + if let previousFile = previousFile { + updatedMedia = !previousFile.isEqual(file) + } else if previousFile == nil { + updatedMedia = true + } + } + } + + let avatarInset: CGFloat = (item.peerId.isGroupOrChannel && item.message.author != nil) ? layoutConstants.avatarDiameter : 0.0 + + var layoutInsets = layoutConstants.instantVideo.insets + if dateHeaderAtBottom { + layoutInsets.top += layoutConstants.timestampHeaderHeight + } + + let videoFrame = 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(radius: videoFrame.size.width / 2.0), imageSize: videoFrame.size, boundingSize: videoFrame.size, intrinsicInsets: UIEdgeInsets()) + + 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.telegramFile = updatedFile + + strongSelf.videoNode.frame = videoFrame + strongSelf.videoNode.transformArguments = arguments + + strongSelf.backgroundNode.frame = videoFrame.insetBy(dx: -2.0, dy: -2.0) + + if let telegramFile = updatedFile, updatedMedia, let context = item.account.applicationContext as? TelegramApplicationContext { + strongSelf.videoNode.acquireContext(account: item.account, mediaManager: context.mediaManager, id: PeerMessageManagedMediaId(messageId: item.message.id), resource: telegramFile.resource) + } + + strongSelf.progressNode?.position = strongSelf.videoNode.position + + 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 + } + } + }) + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) { + super.animateInsertion(currentTimestamp, duration: duration, short: short) + + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + + override func animateAdded(_ currentTimestamp: Double, duration: Double) { + super.animateAdded(currentTimestamp, duration: duration) + + self.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 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 item = self.item, self.videoNode.frame.contains(location) { + self.controllerInteraction?.openMessage(item.message.id) + return + } + + self.controllerInteraction?.clickThroughMessage() + case .longTap, .doubleTap: + if let item = self.item, self.videoNode.frame.contains(location) { + self.controllerInteraction?.openMessageContextMenu(item.message.id, self, self.videoNode.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/ChatMessageInteractiveMediaNode.swift b/TelegramUI/ChatMessageInteractiveMediaNode.swift index eec990ecca..0515217dc0 100644 --- a/TelegramUI/ChatMessageInteractiveMediaNode.swift +++ b/TelegramUI/ChatMessageInteractiveMediaNode.swift @@ -14,6 +14,7 @@ private let secretMediaIcon = generateTintedImage(image: UIImage(bundleImageName final class ChatMessageInteractiveMediaNode: ASTransformNode { private let imageNode: TransformImageNode + private var videoNode: ManagedVideoNode? private var progressNode: RadialProgressNode? private var timeoutNode: RadialTimeoutNode? private var tapRecognizer: UITapGestureRecognizer? @@ -28,6 +29,23 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { private var fetchStatus: MediaResourceStatus? private let fetchDisposable = MetaDisposable() + var visibility: ListViewItemNodeVisibility = .none { + didSet { + if let videoNode = self.videoNode { + switch visibility { + case .visible: + if videoNode.supernode == nil { + self.insertSubnode(videoNode, aboveSubnode: self.imageNode) + } + case .nearlyVisible, .none: + if videoNode.supernode != nil { + videoNode.removeFromSupernode() + } + } + } + } + } + var activateLocalContent: () -> Void = { } init() { @@ -86,11 +104,14 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { } } - func asyncLayout() -> (_ account: Account, _ message: Message, _ media: Media, _ corners: ImageCorners, _ automaticDownload: Bool, _ constrainedSize: CGSize, _ layoutConstants: ChatMessageItemLayoutConstants) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> Void))) { + func asyncLayout() -> (_ account: Account, _ message: Message, _ media: Media, _ corners: ImageCorners, _ automaticDownload: Bool, _ constrainedSize: CGSize, _ layoutConstants: ChatMessageItemLayoutConstants) -> (CGFloat, ImageCorners, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> Void))) { let currentMessageIdAndFlags = self.messageIdAndFlags let currentMedia = self.media let imageLayout = self.imageNode.asyncLayout() + let currentVideoNode = self.videoNode + let hasCurrentVideoNode = currentVideoNode != nil + return { account, message, media, corners, automaticDownload, constrainedSize, layoutConstants in var nativeSize: CGSize @@ -107,6 +128,8 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { } } + var isInlinePlayableVideo = false + if let image = media as? TelegramMediaImage, let dimensions = largestImageRepresentation(image.representations)?.dimensions { nativeSize = CGSize(width: floor(dimensions.width * 0.5), height: floor(dimensions.height * 0.5)).fitted(constrainedSize) } else if let file = media as? TelegramMediaFile, let dimensions = file.dimensions { @@ -114,10 +137,18 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { if file.isAnimated { nativeSize = nativeSize.fitted(CGSize(width: 480.0, height: 480.0)) } + isInlinePlayableVideo = file.isVideo && file.isAnimated } else { nativeSize = CGSize(width: 54.0, height: 54.0) } + var updatedCorners = corners + if isInlinePlayableVideo { + updatedCorners = updatedCorners.withRemovedTails() + let radius = max(updatedCorners.bottomLeft.radius, updatedCorners.bottomRight.radius) + updatedCorners = ImageCorners(radius: radius) + } + let maxWidth: CGFloat if isSecretMedia { maxWidth = 180.0 @@ -130,7 +161,7 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { secretProgressIcon = secretMediaIcon } - return (maxWidth, { constrainedSize in + return (maxWidth, updatedCorners, { constrainedSize in return (min(maxWidth, nativeSize.width), { boundingWidth in let drawingSize: CGSize let boundingSize: CGSize @@ -159,6 +190,10 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { statusUpdated = true } + var updatedVideoNode: ManagedVideoNode? + var replaceVideoNode = false + var updateVideoFile: TelegramMediaFile? + if mediaUpdated { if let image = media as? TelegramMediaImage { if isSecretMedia { @@ -180,6 +215,22 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { } else { updateImageSignal = chatMessageVideo(account: account, video: file) } + + if isInlinePlayableVideo { + updateVideoFile = file + if hasCurrentVideoNode { + } else { + let videoNode = ManagedVideoNode() + videoNode.isUserInteractionEnabled = false + updatedVideoNode = videoNode + replaceVideoNode = true + } + } else { + if hasCurrentVideoNode { + replaceVideoNode = true + } + } + updatedFetchControls = FetchControls(fetch: { [weak self] in if let strongSelf = self { strongSelf.fetchDisposable.set(chatMessageFileInteractiveFetched(account: account, file: file).start()) @@ -216,7 +267,7 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { } } - let arguments = TransformImageArguments(corners: corners, imageSize: drawingSize, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets()) + let arguments = TransformImageArguments(corners: updatedCorners, imageSize: drawingSize, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets()) let imageFrame = CGRect(origin: CGPoint(x: -arguments.insets.left, y: -arguments.insets.top), size: arguments.drawingSize) @@ -232,6 +283,32 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { strongSelf.progressNode?.position = CGPoint(x: imageFrame.midX, y: imageFrame.midY) strongSelf.timeoutNode?.position = CGPoint(x: imageFrame.midX, y: imageFrame.midY) + if replaceVideoNode { + if let videoNode = strongSelf.videoNode { + videoNode.clearContext() + videoNode.removeFromSupernode() + strongSelf.videoNode = nil + } + + if let updatedVideoNode = updatedVideoNode { + strongSelf.videoNode = updatedVideoNode + if strongSelf.visibility == .visible { + strongSelf.insertSubnode(updatedVideoNode, aboveSubnode: strongSelf.imageNode) + } + } + } + + if let videoNode = strongSelf.videoNode { + if let updateVideoFile = updateVideoFile { + if let applicationContext = account.applicationContext as? TelegramApplicationContext { + videoNode.acquireContext(account: account, mediaManager: applicationContext.mediaManager, id: PeerMessageManagedMediaId(messageId: message.id), resource: updateVideoFile.resource) + } + } + + videoNode.transformArguments = arguments + videoNode.frame = imageFrame + } + if let updateImageSignal = updateImageSignal { strongSelf.imageNode.setSignal(account: account, signal: updateImageSignal) } @@ -289,22 +366,27 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { } } + var state: RadialProgressState + var hide = false switch status { case let .Fetching(progress): - strongSelf.progressNode?.state = .Fetching(progress: progress) + state = .Fetching(progress: progress) case .Local: - var state: RadialProgressState = .None + state = .None if isSecretMedia && secretProgressIcon != nil { state = .Image(secretProgressIcon!) } else if let file = media as? TelegramMediaFile { - if file.isVideo { + if !isInlinePlayableVideo && file.isVideo { state = .Play + } else { + hide = true } } - strongSelf.progressNode?.state = state case .Remote: - strongSelf.progressNode?.state = .Remote + state = .Remote } + strongSelf.progressNode?.state = state + strongSelf.progressNode?.isHidden = hide } } })) @@ -327,12 +409,12 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { } } - static func asyncLayout(_ node: ChatMessageInteractiveMediaNode?) -> (_ account: Account, _ message: Message, _ media: Media, _ corners: ImageCorners, _ automaticDownload: Bool, _ constrainedSize: CGSize, _ layoutConstants: ChatMessageItemLayoutConstants) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> ChatMessageInteractiveMediaNode))) { + static func asyncLayout(_ node: ChatMessageInteractiveMediaNode?) -> (_ account: Account, _ message: Message, _ media: Media, _ corners: ImageCorners, _ automaticDownload: Bool, _ constrainedSize: CGSize, _ layoutConstants: ChatMessageItemLayoutConstants) -> (CGFloat, ImageCorners, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> ChatMessageInteractiveMediaNode))) { let currentAsyncLayout = node?.asyncLayout() return { account, message, media, corners, automaticDownload, constrainedSize, layoutConstants in var imageNode: ChatMessageInteractiveMediaNode - var imageLayout: (_ account: Account, _ message: Message, _ media: Media, _ corners: ImageCorners, _ automaticDownload: Bool, _ constrainedSize: CGSize, _ layoutConstants: ChatMessageItemLayoutConstants) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> Void))) + var imageLayout: (_ account: Account, _ message: Message, _ media: Media, _ corners: ImageCorners, _ automaticDownload: Bool, _ constrainedSize: CGSize, _ layoutConstants: ChatMessageItemLayoutConstants) -> (CGFloat, ImageCorners, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> Void))) if let node = node, let currentAsyncLayout = currentAsyncLayout { imageNode = node @@ -342,9 +424,9 @@ final class ChatMessageInteractiveMediaNode: ASTransformNode { imageLayout = imageNode.asyncLayout() } - let (initialWidth, continueLayout) = imageLayout(account, message, media, corners, automaticDownload, constrainedSize, layoutConstants) + let (initialWidth, corners, continueLayout) = imageLayout(account, message, media, corners, automaticDownload, constrainedSize, layoutConstants) - return (initialWidth, { constrainedSize in + return (initialWidth, corners, { constrainedSize in let (finalWidth, finalLayout) = continueLayout(constrainedSize) return (finalWidth, { boundingWidth in diff --git a/TelegramUI/ChatMessageItem.swift b/TelegramUI/ChatMessageItem.swift index ab10420f96..1c372f3217 100644 --- a/TelegramUI/ChatMessageItem.swift +++ b/TelegramUI/ChatMessageItem.swift @@ -119,9 +119,22 @@ public final class ChatMessageItem: ListViewItem, CustomStringConvertible { public func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { var viewClassName: AnyClass = ChatMessageBubbleItemNode.self - for media in message.media { - if let telegramFile = media as? TelegramMediaFile, telegramFile.isSticker { - viewClassName = ChatMessageStickerItemNode.self + loop: for media in message.media { + if let telegramFile = media as? TelegramMediaFile { + for attribute in telegramFile.attributes { + switch attribute { + case .Sticker: + viewClassName = ChatMessageStickerItemNode.self + break loop + case let .Video(_, _, flags): + if flags.contains(.instantRoundVideo) { + viewClassName = ChatMessageInstantVideoItemNode.self + break loop + } + default: + break + } + } } else if let _ = media as? TelegramMediaAction { viewClassName = ChatMessageActionItemNode.self } diff --git a/TelegramUI/ChatMessageItemView.swift b/TelegramUI/ChatMessageItemView.swift index b1801cd963..865b9b4012 100644 --- a/TelegramUI/ChatMessageItemView.swift +++ b/TelegramUI/ChatMessageItemView.swift @@ -25,6 +25,11 @@ struct ChatMessageItemImageLayoutConstants { let maxDimensions: CGSize } +struct ChatMessageItemInstantVideoConstants { + let insets: UIEdgeInsets + let dimensions: CGSize +} + struct ChatMessageItemFileLayoutConstants { let bubbleInsets: UIEdgeInsets } @@ -37,6 +42,7 @@ struct ChatMessageItemLayoutConstants { let image: ChatMessageItemImageLayoutConstants let text: ChatMessageItemTextLayoutConstants let file: ChatMessageItemFileLayoutConstants + let instantVideo: ChatMessageItemInstantVideoConstants init() { self.avatarDiameter = 37.0 @@ -46,6 +52,7 @@ struct ChatMessageItemLayoutConstants { self.text = ChatMessageItemTextLayoutConstants(bubbleInsets: UIEdgeInsets(top: 6.0 + UIScreenPixel, left: 12.0, bottom: 6.0 - UIScreenPixel, right: 12.0)) self.image = ChatMessageItemImageLayoutConstants(bubbleInsets: UIEdgeInsets(top: 0.5, left: 0.5, bottom: 0.5, right: 0.5), statusInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 6.0, right: 6.0), defaultCornerRadius: 17.0, mergedCornerRadius: 5.0, contentMergedCornerRadius: 5.0, maxDimensions: CGSize(width: 260.0, height: 260.0)) self.file = ChatMessageItemFileLayoutConstants(bubbleInsets: UIEdgeInsets(top: 15.0, left: 9.0, bottom: 15.0, right: 12.0)) + self.instantVideo = ChatMessageItemInstantVideoConstants(insets: UIEdgeInsets(top: 4.0, left: 0.0, bottom: 4.0, right: 0.0), dimensions: CGSize(width: 212.0, height: 212.0)) } } @@ -63,7 +70,7 @@ 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) + self.transform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0) } required public init?(coder aDecoder: NSCoder) { diff --git a/TelegramUI/ChatMessageMediaBubbleContentNode.swift b/TelegramUI/ChatMessageMediaBubbleContentNode.swift index 5731394a62..1366dc02b4 100644 --- a/TelegramUI/ChatMessageMediaBubbleContentNode.swift +++ b/TelegramUI/ChatMessageMediaBubbleContentNode.swift @@ -16,6 +16,12 @@ class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { private var item: ChatMessageItem? private var media: Media? + override var visibility: ListViewItemNodeVisibility { + didSet { + self.interactiveImageNode.visibility = self.visibility + } + } + required init() { self.interactiveImageNode = ChatMessageInteractiveMediaNode() self.dateAndStatusNode = ChatMessageDateAndStatusNode() @@ -51,9 +57,9 @@ class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { } } - let imageCorners = chatMessageBubbleImageContentCorners(relativeContentPosition: position, normalRadius: layoutConstants.image.defaultCornerRadius, mergedRadius: layoutConstants.image.mergedCornerRadius, mergedWithAnotherContentRadius: layoutConstants.image.contentMergedCornerRadius) + let initialImageCorners = chatMessageBubbleImageContentCorners(relativeContentPosition: position, normalRadius: layoutConstants.image.defaultCornerRadius, mergedRadius: layoutConstants.image.mergedCornerRadius, mergedWithAnotherContentRadius: layoutConstants.image.contentMergedCornerRadius) - let (initialWidth, refineLayout) = interactiveImageLayout(item.account, item.message, selectedMedia!, imageCorners, item.account.settings.automaticDownloadSettingsForPeerId(item.peerId).downloadPhoto, CGSize(width: constrainedSize.width, height: constrainedSize.height), layoutConstants) + let (initialWidth, _, refineLayout) = interactiveImageLayout(item.account, item.message, selectedMedia!, initialImageCorners, item.account.settings.automaticDownloadSettingsForPeerId(item.peerId).downloadPhotos, CGSize(width: constrainedSize.width, height: constrainedSize.height), layoutConstants) return (initialWidth + layoutConstants.image.bubbleInsets.left + layoutConstants.image.bubbleInsets.right, { constrainedSize in let (refinedWidth, finishLayout) = refineLayout(constrainedSize) diff --git a/TelegramUI/ChatMessageNotificationItem.swift b/TelegramUI/ChatMessageNotificationItem.swift new file mode 100644 index 0000000000..d7d9bb3e8f --- /dev/null +++ b/TelegramUI/ChatMessageNotificationItem.swift @@ -0,0 +1,202 @@ +import Foundation +import AsyncDisplayKit +import Display +import Postbox +import TelegramCore +import SwiftSignalKit + +public final class ChatMessageNotificationItem: NotificationItem { + let account: Account + let message: Message + let tapAction: () -> Void + + public var groupingKey: AnyHashable? { + return message.id.peerId + } + + public init(account: Account, message: Message, tapAction: @escaping () -> Void) { + self.account = account + self.message = message + self.tapAction = tapAction + } + + public func node() -> NotificationItemNode { + let node = ChatMessageNotificationItemNode() + node.setupItem(self) + return node + } + + public func tapped() { + self.tapAction() + } +} + +private let avatarFont: UIFont = UIFont(name: "ArialRoundedMTBold", size: 24.0)! + +final class ChatMessageNotificationItemNode: NotificationItemNode { + private var item: ChatMessageNotificationItem? + + private let avatarNode: AvatarNode + private let titleNode: ASTextNode + private let textNode: ASTextNode + private let imageNode: TransformImageNode + + override init() { + self.avatarNode = AvatarNode(font: avatarFont) + + self.titleNode = ASTextNode() + self.titleNode.isLayerBacked = true + self.titleNode.maximumNumberOfLines = 1 + //self.titleNode.contentMode = .topLeft + + self.textNode = ASTextNode() + self.textNode.isLayerBacked = true + self.textNode.maximumNumberOfLines = 2 + //self.textNode.contentMode = .topLeft + + self.imageNode = TransformImageNode() + + super.init() + + self.addSubnode(self.avatarNode) + self.addSubnode(self.titleNode) + self.addSubnode(self.textNode) + self.addSubnode(self.imageNode) + } + + func setupItem(_ item: ChatMessageNotificationItem) { + self.item = item + + if let peer = messageMainPeer(item.message) { + self.avatarNode.setPeer(account: item.account, peer: peer) + } + + if let peer = item.message.peers[item.message.id.peerId] { + self.titleNode.attributedText = NSAttributedString(string: peer.displayTitle, font: Font.semibold(16.0), textColor: .black) + } + + var updatedMedia: Media? + var imageDimensions: CGSize? + for media in item.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) { + imageDimensions = representation.dimensions + } + break + } + } + + let imageNodeLayout = self.imageNode.asyncLayout() + var applyImage: (() -> Void)? + if let imageDimensions = imageDimensions { + let boundingSize = CGSize(width: 55.0, height: 55.0) + applyImage = imageNodeLayout(TransformImageArguments(corners: ImageCorners(radius: 6.0), imageSize: imageDimensions.aspectFilled(boundingSize), boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets())) + } + + var updateImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? + if let updatedMedia = updatedMedia, imageDimensions != nil { + if let image = updatedMedia as? TelegramMediaImage { + updateImageSignal = mediaGridMessagePhoto(account: item.account, photo: image) + } else if let file = updatedMedia as? TelegramMediaFile { + if file.isSticker { + updateImageSignal = chatMessageSticker(account: item.account, file: file, small: true, fetched: true) + } else if file.isVideo { + updateImageSignal = mediaGridMessageVideo(account: item.account, video: file) + } + } + } + + var messageText = item.message.text + for media in item.message.media { + switch media { + case _ as TelegramMediaImage: + if messageText.isEmpty { + messageText = "Photo" + } + case let file as TelegramMediaFile: + var selectedText = false + loop: for attribute in file.attributes { + switch attribute { + case let .Audio(isVoice, _, title, performer, _): + if isVoice { + messageText = "Voice Message" + } else { + if let title = title, let performer = performer, !title.isEmpty, !performer.isEmpty { + messageText = title + " — " + performer + } else if let title = title, !title.isEmpty { + messageText = title + } else if let performer = performer, !performer.isEmpty { + messageText = performer + } else { + messageText = "Audio" + } + } + selectedText = true + break loop + case let .Sticker(displayText, _): + messageText = "\(displayText) Sticker" + selectedText = true + break loop + case .Video: + if messageText.isEmpty { + messageText = "Video" + } + selectedText = true + break loop + default: + break + } + } + if !selectedText { + messageText = file.fileName ?? "File" + } + default: + break + } + } + + if let applyImage = applyImage { + applyImage() + self.imageNode.isHidden = false + } else { + self.imageNode.isHidden = true + } + + if let updateImageSignal = updateImageSignal { + self.imageNode.setSignal(account: item.account, signal: updateImageSignal) + } + + self.textNode.attributedText = NSAttributedString(string: messageText, font: Font.regular(16.0), textColor: .black) + } + + override func updateLayout(width: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { + let panelHeight: CGFloat = 74.0 + let leftInset: CGFloat = 77.0 + var rightInset: CGFloat = 8.0 + + if !self.imageNode.isHidden { + rightInset += 55.0 + 8.0 + } + + transition.updateFrame(node: self.avatarNode, frame: CGRect(origin: CGPoint(x: 10.0, y: 10.0), size: CGSize(width: 54.0, height: 54.0))) + + let textSize = self.textNode.measure(CGSize(width: width - leftInset - rightInset, height: CGFloat.greatestFiniteMagnitude)) + let textSpacing: CGFloat = -2.0 + + let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: 1.0 + floor((panelHeight - textSize.height - 22.0) / 2.0)), size: CGSize(width: width - leftInset - rightInset, height: 22.0)) + transition.updateFrame(node: self.titleNode, frame: titleFrame) + + transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: leftInset, y: titleFrame.maxY + textSpacing), size: textSize)) + + transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(x: width - 9.0 - 55.0, y: 9.0), size: CGSize(width: 55.0, height: 55.0))) + + return 74.0 + } +} diff --git a/TelegramUI/ChatMessageSelectionNode.swift b/TelegramUI/ChatMessageSelectionNode.swift index e13d4ba9fa..d18d8ceb36 100644 --- a/TelegramUI/ChatMessageSelectionNode.swift +++ b/TelegramUI/ChatMessageSelectionNode.swift @@ -35,6 +35,9 @@ final class ChatMessageSelectionNode: ASDisplayNode { if self.selected != selected { self.selected = selected self.checkNode.image = selected ? checkedImage : uncheckedImage + if animated { + self.checkNode.layer.animateSpring(from: 0.8 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.4) + } } } diff --git a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift index fd11efa0d6..5d41ed0e66 100644 --- a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift +++ b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift @@ -148,7 +148,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { if let file = webpage.file { if file.isVideo { - let (initialImageWidth, refineLayout) = contentImageLayout(item.account, item.message, file, ImageCorners(radius: 4.0), true, CGSize(width: constrainedSize.width - insets.left - insets.right, height: constrainedSize.height), layoutConstants) + let (initialImageWidth, _, refineLayout) = contentImageLayout(item.account, item.message, file, ImageCorners(radius: 4.0), true, CGSize(width: constrainedSize.width - insets.left - insets.right, height: constrainedSize.height), layoutConstants) initialWidth = initialImageWidth + insets.left + insets.right refineContentImageLayout = refineLayout } else { @@ -161,7 +161,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { } } else if let image = webpage.image { if let type = webpage.type, ["photo"].contains(type) { - let (initialImageWidth, refineLayout) = contentImageLayout(item.account, item.message, image, ImageCorners(radius: 4.0), true, CGSize(width: constrainedSize.width - insets.left - insets.right, height: constrainedSize.height), layoutConstants) + let (initialImageWidth, _, refineLayout) = contentImageLayout(item.account, item.message, image, ImageCorners(radius: 4.0), true, CGSize(width: constrainedSize.width - insets.left - insets.right, height: constrainedSize.height), layoutConstants) initialWidth = initialImageWidth + insets.left + insets.right refineContentImageLayout = refineLayout } else if let dimensions = largestImageRepresentation(image.representations)?.dimensions { diff --git a/TelegramUI/ChatSecretAutoremoveTimerActionSheet.swift b/TelegramUI/ChatSecretAutoremoveTimerActionSheet.swift index b0d11f03f9..d1e8d1d654 100644 --- a/TelegramUI/ChatSecretAutoremoveTimerActionSheet.swift +++ b/TelegramUI/ChatSecretAutoremoveTimerActionSheet.swift @@ -54,6 +54,9 @@ private final class AutoremoveTimeoutSelectorItem: ActionSheetItem { func node() -> ActionSheetItemNode { return AutoremoveTimeoutSelectorItemNode(currentValue: self.currentValue, valueChanged: self.valueChanged) } + + func updateNode(_ node: ActionSheetItemNode) { + } } private let timeoutValues: [(Int32, String)] = [ diff --git a/TelegramUI/ChatTitleView.swift b/TelegramUI/ChatTitleView.swift index a8a474d60f..05320a0fd6 100644 --- a/TelegramUI/ChatTitleView.swift +++ b/TelegramUI/ChatTitleView.swift @@ -68,6 +68,10 @@ final class ChatTitleView: UIView { self.typingIndicator?.setUploading() case .playingGame: self.typingIndicator?.setPlaying() + case .recordingInstantVideo: + self.typingIndicator?.setAudioRecording() + case .uploadingInstantVideo: + self.typingIndicator?.setUploading() } } else { self.typingNode.isHidden = true diff --git a/TelegramUI/ChatVideoGalleryItem.swift b/TelegramUI/ChatVideoGalleryItem.swift index f695504f8d..a060992c0e 100644 --- a/TelegramUI/ChatVideoGalleryItem.swift +++ b/TelegramUI/ChatVideoGalleryItem.swift @@ -17,7 +17,7 @@ class ChatVideoGalleryItem: GalleryItem { } func node() -> GalleryItemNode { - let node = ChatVideoGalleryItemNode() + let node = ChatVideoGalleryItemNode(account: self.account) for media in self.message.media { if let file = media as? TelegramMediaFile, (file.isVideo || file.mimeType.hasPrefix("video/")) { @@ -34,6 +34,7 @@ class ChatVideoGalleryItem: GalleryItem { if let location = self.location { node._title.set(.single("\(location.index + 1) of \(location.count)")) } + node.setMessage(self.message) return node } @@ -41,6 +42,7 @@ class ChatVideoGalleryItem: GalleryItem { func updateNode(node: GalleryItemNode) { if let node = node as? ChatVideoGalleryItemNode, let location = self.location { node._title.set(.single("\(location.index + 1) of \(location.count)")) + node.setMessage(self.message) } } } @@ -59,6 +61,7 @@ final class ChatVideoGalleryItemNode: ZoomableContentGalleryItemNode { private let progressNode: RadialProgressNode private var accountAndFile: (Account, TelegramMediaFile, Bool)? + private var message: Message? private var isCentral = false @@ -66,7 +69,9 @@ final class ChatVideoGalleryItemNode: ZoomableContentGalleryItemNode { private let fetchDisposable = MetaDisposable() private var resourceStatus: MediaResourceStatus? - override init() { + private let footerContentNode: ChatItemGalleryFooterContentNode + + init(account: Account) { self.videoNode = MediaPlayerNode() self.snapshotNode = TransformImageNode() self.snapshotNode.backgroundColor = UIColor.black @@ -76,6 +81,8 @@ final class ChatVideoGalleryItemNode: ZoomableContentGalleryItemNode { self.progressButtonNode = HighlightableButtonNode() self.progressNode = RadialProgressNode(theme: RadialProgressTheme(backgroundColor: UIColor(white: 0.0, alpha: 0.6), foregroundColor: UIColor.white, icon: nil)) + self.footerContentNode = ChatItemGalleryFooterContentNode(account: account) + super.init() self.snapshotNode.imageUpdated = { [weak self] in @@ -109,6 +116,10 @@ final class ChatVideoGalleryItemNode: ZoomableContentGalleryItemNode { transition.updateFrame(node: self.progressNode, frame: CGRect(origin: CGPoint(), size: progressFrame.size)) } + fileprivate func setMessage(_ message: Message) { + self.footerContentNode.setMessage(message) + } + func setFile(account: Account, file: TelegramMediaFile, loopVideo: Bool) { if self.accountAndFile == nil || !self.accountAndFile!.1.isEqual(file) || !self.accountAndFile!.2 != loopVideo { if let largestSize = file.dimensions { @@ -165,7 +176,7 @@ final class ChatVideoGalleryItemNode: ZoomableContentGalleryItemNode { /*let source = VideoPlayerSource(account: account, resource: CloudFileMediaResource(location: file.location, size: file.size)) self.videoNode.player = VideoPlayer(source: source)*/ - let player = MediaPlayer(audioSessionManager: (account.applicationContext as! TelegramApplicationContext).mediaManager.audioSession, postbox: account.postbox, resource: file.resource, streamable: false) + let player = MediaPlayer(audioSessionManager: (account.applicationContext as! TelegramApplicationContext).mediaManager.audioSession, postbox: account.postbox, resource: file.resource, streamable: false, video: true, preferSoftwareDecoding: false, enableSound: true) if loopVideo { player.actionAtEnd = .loop } @@ -284,7 +295,7 @@ final class ChatVideoGalleryItemNode: ZoomableContentGalleryItemNode { case .Local: self.playVideo() case .Remote: - self.fetchDisposable.set(account.postbox.mediaBox.fetchedResource(file.resource).start()) + self.fetchDisposable.set(account.postbox.mediaBox.fetchedResource(file.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .video)).start()) } } } @@ -299,9 +310,13 @@ final class ChatVideoGalleryItemNode: ZoomableContentGalleryItemNode { case .Local: self.playVideo() case .Remote: - self.fetchDisposable.set(account.postbox.mediaBox.fetchedResource(file.resource).start()) + self.fetchDisposable.set(account.postbox.mediaBox.fetchedResource(file.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .video)).start()) } } } } + + override func footerContent() -> Signal { + return .single(self.footerContentNode) + } } diff --git a/TelegramUI/ContactMultiselectionController.swift b/TelegramUI/ContactMultiselectionController.swift index c7b33c6280..98c46daa12 100644 --- a/TelegramUI/ContactMultiselectionController.swift +++ b/TelegramUI/ContactMultiselectionController.swift @@ -195,8 +195,8 @@ public class ContactMultiselectionController: ViewController { } } - override open func dismiss() { - self.contactsNode.animateOut() + override open func dismiss(completion: (() -> Void)? = nil) { + self.contactsNode.animateOut(completion: completion) } override public func viewDidDisappear(_ animated: Bool) { diff --git a/TelegramUI/ContactMultiselectionControllerNode.swift b/TelegramUI/ContactMultiselectionControllerNode.swift index d8e78d0094..acbbd6d431 100644 --- a/TelegramUI/ContactMultiselectionControllerNode.swift +++ b/TelegramUI/ContactMultiselectionControllerNode.swift @@ -137,10 +137,11 @@ final class ContactMultiselectionControllerNode: ASDisplayNode { 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() { + func animateOut(completion: (() -> Void)?) { 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?() + completion?() } }) } diff --git a/TelegramUI/ContactSelectionController.swift b/TelegramUI/ContactSelectionController.swift index c50df545c9..fdd57f389f 100644 --- a/TelegramUI/ContactSelectionController.swift +++ b/TelegramUI/ContactSelectionController.swift @@ -177,13 +177,13 @@ public class ContactSelectionController: ViewController { })) } - override open func dismiss() { + override open func dismiss(completion: (() -> Void)? = nil) { if let presentationArguments = self.presentationArguments as? ViewControllerPresentationArguments { switch presentationArguments.presentationAnimation { case .modalSheet: - self.contactsNode.animateOut() + self.contactsNode.animateOut(completion: completion) case .none: - break + completion?() } } } diff --git a/TelegramUI/ContactSelectionControllerNode.swift b/TelegramUI/ContactSelectionControllerNode.swift index 681640d714..abb677ab69 100644 --- a/TelegramUI/ContactSelectionControllerNode.swift +++ b/TelegramUI/ContactSelectionControllerNode.swift @@ -99,11 +99,12 @@ final class ContactSelectionControllerNode: ASDisplayNode { 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() { + func animateOut(completion: (() -> Void)? = nil) { 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?() } + completion?() }) } } diff --git a/TelegramUI/ConvertToSupergroupController.swift b/TelegramUI/ConvertToSupergroupController.swift index 64c19f6c59..d163edd448 100644 --- a/TelegramUI/ConvertToSupergroupController.swift +++ b/TelegramUI/ConvertToSupergroupController.swift @@ -132,7 +132,7 @@ public func convertToSupergroupController(account: Account, peerId: PeerId) -> V rightNavigationButton = ItemListNavigationButton(title: "", style: .activity, enabled: true, action: {}) } - let controllerState = ItemListControllerState(title: "Supergroup", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton) + let controllerState = ItemListControllerState(title: .text("Supergroup"), leftNavigationButton: nil, rightNavigationButton: rightNavigationButton) let listState = ItemListNodeState(entries: convertToSupergroupEntries(), style: .blocks) return (controllerState, (listState, arguments)) diff --git a/TelegramUI/CreateChannelController.swift b/TelegramUI/CreateChannelController.swift index edbafad043..e4a43d60ee 100644 --- a/TelegramUI/CreateChannelController.swift +++ b/TelegramUI/CreateChannelController.swift @@ -94,6 +94,7 @@ private enum CreateChannelEntry: ItemListNodeEntry { case let .channelInfo(peer, state): return ItemListAvatarAndNameInfoItem(account: arguments.account, peer: peer, presence: nil, cachedData: nil, state: state, sectionId: ItemListSectionId(self.section), style: .blocks, editingNameUpdated: { editingName in arguments.updateEditingName(editingName) + }, avatarTapped: { }) case .setProfilePhoto: return ItemListActionItem(title: "Set Profile Photo", kind: .generic, alignment: .natural, sectionId: ItemListSectionId(self.section), style: .blocks, action: { @@ -217,7 +218,7 @@ public func createChannelController(account: Account) -> ViewController { }) } - let controllerState = ItemListControllerState(title: "Create Channel", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton) + let controllerState = ItemListControllerState(title: .text("Create Channel"), leftNavigationButton: nil, rightNavigationButton: rightNavigationButton) let listState = ItemListNodeState(entries: CreateChannelEntries(state: state), style: .blocks) return (controllerState, (listState, arguments)) diff --git a/TelegramUI/CreateGroupController.swift b/TelegramUI/CreateGroupController.swift index 58fc242959..b0a9f3e7ce 100644 --- a/TelegramUI/CreateGroupController.swift +++ b/TelegramUI/CreateGroupController.swift @@ -98,13 +98,14 @@ private enum CreateGroupEntry: ItemListNodeEntry { case let .groupInfo(peer, state): return ItemListAvatarAndNameInfoItem(account: arguments.account, peer: peer, presence: nil, cachedData: nil, state: state, sectionId: ItemListSectionId(self.section), style: .blocks, editingNameUpdated: { editingName in arguments.updateEditingName(editingName) + }, avatarTapped: { }) case .setProfilePhoto: return ItemListActionItem(title: "Set Profile Photo", kind: .generic, alignment: .natural, sectionId: ItemListSectionId(self.section), style: .blocks, action: { }) case let .member(_, peer, presence): - return ItemListPeerItem(account: arguments.account, peer: peer, presence: presence, text: .presence, label: nil, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: false), switchValue: nil, enabled: true, sectionId: self.section, action: nil, setPeerIdWithRevealedOptions: { _ in }, removePeer: { _ in }) + return ItemListPeerItem(account: arguments.account, peer: peer, presence: presence, text: .presence, label: .none, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: false), switchValue: nil, enabled: true, sectionId: self.section, action: nil, setPeerIdWithRevealedOptions: { _ in }, removePeer: { _ in }) } } } @@ -221,7 +222,7 @@ public func createGroupController(account: Account, peerIds: [PeerId]) -> ViewCo }) } - let controllerState = ItemListControllerState(title: "Create Group", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton) + let controllerState = ItemListControllerState(title: .text("Create Group"), leftNavigationButton: nil, rightNavigationButton: rightNavigationButton) let listState = ItemListNodeState(entries: createGroupEntries(state: state, peerIds: peerIds, view: view), style: .blocks) return (controllerState, (listState, arguments)) diff --git a/TelegramUI/DataAndStorageSettingsController.swift b/TelegramUI/DataAndStorageSettingsController.swift index 8b7881213c..a64017f3fd 100644 --- a/TelegramUI/DataAndStorageSettingsController.swift +++ b/TelegramUI/DataAndStorageSettingsController.swift @@ -1,45 +1,459 @@ import Foundation import Display -import Postbox import SwiftSignalKit +import Postbox import TelegramCore -import MtProtoKitDynamic +import TelegramLegacyComponents -public class DataAndStorageSettingsController: ListController { - private let account: Account +private enum AutomaticDownloadCategory { + case photo + case voice + case instantVideo + case gif +} + +private enum AutomaticDownloadPeers { + case privateChats + case groupsAndChannels +} + +private final class DataAndStorageControllerArguments { + let openStorageUsage: () -> Void + let openNetworkUsage: () -> Void + let toggleAutomaticDownload: (AutomaticDownloadCategory, AutomaticDownloadPeers, Bool) -> Void + let openVoiceUseLessData: () -> Void + let toggleSaveIncomingPhotos: (Bool) -> Void + let toggleSaveEditedPhotos: (Bool) -> Void - private var currentStatsDisposable: Disposable? - - public init(account: Account) { - self.account = account - - super.init() - - self.title = "Data and Storage" - - let deselectAction = { [weak self] () -> Void in - self?.listDisplayNode.listView.clearHighlightAnimated(true) - } - - self.items = [ - ListControllerDisclosureActionItem(title: "Bytes Sent", action: deselectAction), - ListControllerDisclosureActionItem(title: "Bytes Received", action: deselectAction), - ] - - /*self.currentStatsDisposable = (((account.currentNetworkStats() |> then(Signal.complete() |> delay(1.0, queue: Queue.concurrentDefaultQueue()))) |> restart) |> deliverOnMainQueue).start(next: { [weak self] stats in - if let strongSelf = self { - let incoming = stats.wwan.incomingBytes + stats.other.incomingBytes - let outgoing = stats.wwan.outgoingBytes + stats.other.outgoingBytes - strongSelf.listDisplayNode.listView.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [ListViewUpdateItem(index: 0, previousIndex: 0, item: ListControllerDisclosureActionItem(title: "Bytes Sent: \(outgoing / 1024) KB", action: deselectAction), directionHint: nil), ListViewUpdateItem(index: 1, previousIndex: 1, item: ListControllerDisclosureActionItem(title: "Bytes Received: \(incoming / 1024) KB", action: deselectAction), directionHint: nil)], options: [.AnimateInsertion], updateOpaqueState: nil) - } - })*/ - } - - required public init(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - currentStatsDisposable?.dispose() + init(openStorageUsage: @escaping () -> Void, openNetworkUsage: @escaping () -> Void, toggleAutomaticDownload: @escaping (AutomaticDownloadCategory, AutomaticDownloadPeers, Bool) -> Void, openVoiceUseLessData: @escaping () -> Void, toggleSaveIncomingPhotos: @escaping (Bool) -> Void, toggleSaveEditedPhotos: @escaping (Bool) -> Void) { + self.openStorageUsage = openStorageUsage + self.openNetworkUsage = openNetworkUsage + self.toggleAutomaticDownload = toggleAutomaticDownload + self.openVoiceUseLessData = openVoiceUseLessData + self.toggleSaveIncomingPhotos = toggleSaveIncomingPhotos + self.toggleSaveEditedPhotos = toggleSaveEditedPhotos } } + +private enum DataAndStorageSection: Int32 { + case usage + case automaticPhotoDownload + case automaticVoiceDownload + case automaticInstantVideoDownload + case voiceCalls + case other +} + +private enum DataAndStorageEntry: ItemListNodeEntry { + case storageUsage(String) + case networkUsage(String) + case automaticPhotoDownloadHeader(String) + case automaticPhotoDownloadPrivateChats(String, Bool) + case automaticPhotoDownloadGroupsAndChannels(String, Bool) + case automaticVoiceDownloadHeader(String) + case automaticVoiceDownloadPrivateChats(String, Bool) + case automaticVoiceDownloadGroupsAndChannels(String, Bool) + case automaticInstantVideoDownloadHeader(String) + case automaticInstantVideoDownloadPrivateChats(String, Bool) + case automaticInstantVideoDownloadGroupsAndChannels(String, Bool) + case voiceCallsHeader(String) + case useLessVoiceData(String, String) + case otherHeader(String) + case saveIncomingPhotos(String, Bool) + case saveEditedPhotos(String, Bool) + + var section: ItemListSectionId { + switch self { + case .storageUsage, .networkUsage: + return DataAndStorageSection.usage.rawValue + case .automaticPhotoDownloadHeader, .automaticPhotoDownloadPrivateChats, .automaticPhotoDownloadGroupsAndChannels: + return DataAndStorageSection.automaticPhotoDownload.rawValue + case .automaticVoiceDownloadHeader, .automaticVoiceDownloadPrivateChats, .automaticVoiceDownloadGroupsAndChannels: + return DataAndStorageSection.automaticVoiceDownload.rawValue + case .automaticInstantVideoDownloadHeader, .automaticInstantVideoDownloadPrivateChats, .automaticInstantVideoDownloadGroupsAndChannels: + return DataAndStorageSection.automaticInstantVideoDownload.rawValue + case .voiceCallsHeader, .useLessVoiceData: + return DataAndStorageSection.voiceCalls.rawValue + case .otherHeader, .saveIncomingPhotos, .saveEditedPhotos: + return DataAndStorageSection.other.rawValue + } + } + + var stableId: Int32 { + switch self { + case .storageUsage: + return 0 + case .networkUsage: + return 1 + case .automaticPhotoDownloadHeader: + return 2 + case .automaticPhotoDownloadPrivateChats: + return 3 + case .automaticPhotoDownloadGroupsAndChannels: + return 4 + case .automaticVoiceDownloadHeader: + return 5 + case .automaticVoiceDownloadPrivateChats: + return 6 + case .automaticVoiceDownloadGroupsAndChannels: + return 7 + case .automaticInstantVideoDownloadHeader: + return 8 + case .automaticInstantVideoDownloadPrivateChats: + return 9 + case .automaticInstantVideoDownloadGroupsAndChannels: + return 10 + case .voiceCallsHeader: + return 11 + case .useLessVoiceData: + return 12 + case .otherHeader: + return 13 + case .saveIncomingPhotos: + return 14 + case .saveEditedPhotos: + return 15 + } + } + + static func ==(lhs: DataAndStorageEntry, rhs: DataAndStorageEntry) -> Bool { + switch lhs { + case let .storageUsage(text): + if case .storageUsage(text) = rhs { + return true + } else { + return false + } + case let .networkUsage(text): + if case .networkUsage(text) = rhs { + return true + } else { + return false + } + case let .automaticPhotoDownloadHeader(text): + if case .automaticPhotoDownloadHeader(text) = rhs { + return true + } else { + return false + } + case let .automaticPhotoDownloadPrivateChats(text, value): + if case .automaticPhotoDownloadPrivateChats(text, value) = rhs { + return true + } else { + return false + } + case let .automaticPhotoDownloadGroupsAndChannels(text, value): + if case .automaticPhotoDownloadGroupsAndChannels(text, value) = rhs { + return true + } else { + return false + } + case let .automaticVoiceDownloadHeader(text): + if case .automaticVoiceDownloadHeader(text) = rhs { + return true + } else { + return false + } + case let .automaticVoiceDownloadPrivateChats(text, value): + if case .automaticVoiceDownloadPrivateChats(text, value) = rhs { + return true + } else { + return false + } + case let .automaticVoiceDownloadGroupsAndChannels(text, value): + if case .automaticVoiceDownloadGroupsAndChannels(text, value) = rhs { + return true + } else { + return false + } + case let .automaticInstantVideoDownloadHeader(text): + if case .automaticInstantVideoDownloadHeader(text) = rhs { + return true + } else { + return false + } + case let .automaticInstantVideoDownloadPrivateChats(text, value): + if case .automaticInstantVideoDownloadPrivateChats(text, value) = rhs { + return true + } else { + return false + } + case let .automaticInstantVideoDownloadGroupsAndChannels(text, value): + if case .automaticInstantVideoDownloadGroupsAndChannels(text, value) = rhs { + return true + } else { + return false + } + case let .voiceCallsHeader(text): + if case .voiceCallsHeader(text) = rhs { + return true + } else { + return false + } + case let .useLessVoiceData(text, value): + if case .useLessVoiceData(text, value) = rhs { + return true + } else { + return false + } + case let .otherHeader(text): + if case .otherHeader(text) = rhs { + return true + } else { + return false + } + case let .saveIncomingPhotos(text, value): + if case .saveIncomingPhotos(text, value) = rhs { + return true + } else { + return false + } + case let .saveEditedPhotos(text, value): + if case .saveEditedPhotos(text, value) = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: DataAndStorageEntry, rhs: DataAndStorageEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(_ arguments: DataAndStorageControllerArguments) -> ListViewItem { + switch self { + case let .storageUsage(text): + return ItemListDisclosureItem(title: text, label: "", sectionId: self.section, style: .blocks, action: { + arguments.openStorageUsage() + }) + case let .networkUsage(text): + return ItemListDisclosureItem(title: text, label: "", sectionId: self.section, style: .blocks, action: { + arguments.openNetworkUsage() + }) + case let .automaticPhotoDownloadHeader(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + case let .automaticPhotoDownloadPrivateChats(text, value): + return ItemListSwitchItem(title: text, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.toggleAutomaticDownload(.photo, .privateChats, value) + }) + case let .automaticPhotoDownloadGroupsAndChannels(text, value): + return ItemListSwitchItem(title: text, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.toggleAutomaticDownload(.photo, .groupsAndChannels, value) + }) + case let .automaticVoiceDownloadHeader(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + case let .automaticVoiceDownloadPrivateChats(text, value): + return ItemListSwitchItem(title: text, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.toggleAutomaticDownload(.voice, .privateChats, value) + }) + case let .automaticVoiceDownloadGroupsAndChannels(text, value): + return ItemListSwitchItem(title: text, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.toggleAutomaticDownload(.voice, .groupsAndChannels, value) + }) + case let .automaticInstantVideoDownloadHeader(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + case let .automaticInstantVideoDownloadPrivateChats(text, value): + return ItemListSwitchItem(title: text, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.toggleAutomaticDownload(.instantVideo, .privateChats, value) + }) + case let .automaticInstantVideoDownloadGroupsAndChannels(text, value): + return ItemListSwitchItem(title: text, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.toggleAutomaticDownload(.instantVideo, .groupsAndChannels, value) + }) + case let .voiceCallsHeader(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + case let .useLessVoiceData(text, value): + return ItemListDisclosureItem(title: text, label: value, sectionId: self.section, style: .blocks, action: { + arguments.openVoiceUseLessData() + }) + case let .otherHeader(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + case let .saveIncomingPhotos(text, value): + return ItemListSwitchItem(title: text, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.toggleSaveIncomingPhotos(value) + }) + case let .saveEditedPhotos(text, value): + return ItemListSwitchItem(title: text, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.toggleSaveEditedPhotos(value) + }) + } + } +} + +private struct DataAndStorageControllerState: Equatable { + static func ==(lhs: DataAndStorageControllerState, rhs: DataAndStorageControllerState) -> Bool { + return true + } +} + +private struct DataAndStorageData: Equatable { + let automaticMediaDownloadSettings: AutomaticMediaDownloadSettings + let generatedMediaStoreSettings: GeneratedMediaStoreSettings + let voiceCallSettings: VoiceCallSettings + + init(automaticMediaDownloadSettings: AutomaticMediaDownloadSettings, generatedMediaStoreSettings: GeneratedMediaStoreSettings, voiceCallSettings: VoiceCallSettings) { + self.automaticMediaDownloadSettings = automaticMediaDownloadSettings + self.generatedMediaStoreSettings = generatedMediaStoreSettings + self.voiceCallSettings = voiceCallSettings + } + + static func ==(lhs: DataAndStorageData, rhs: DataAndStorageData) -> Bool { + return lhs.automaticMediaDownloadSettings == rhs.automaticMediaDownloadSettings && lhs.generatedMediaStoreSettings == rhs.generatedMediaStoreSettings && lhs.voiceCallSettings == rhs.voiceCallSettings + } +} + +private func stringForUseLessDataSetting(_ settings: VoiceCallSettings) -> String { + switch settings.dataSaving { + case .never: + return "Never" + case .cellular: + return "On Mobile Network" + case .always: + return "Always" + } +} + +private func dataAndStorageControllerEntries(state: DataAndStorageControllerState, data: DataAndStorageData) -> [DataAndStorageEntry] { + var entries: [DataAndStorageEntry] = [] + + entries.append(.storageUsage("Storage Usage")) + entries.append(.networkUsage("Network Usage")) + + entries.append(.automaticPhotoDownloadHeader("AUTOMATIC PHOTO DOWNLOAD")) + entries.append(.automaticPhotoDownloadPrivateChats("Private Chats", data.automaticMediaDownloadSettings.categories.photo.privateChats)) + entries.append(.automaticPhotoDownloadGroupsAndChannels("Groups and Channels", data.automaticMediaDownloadSettings.categories.photo.groupsAndChannels)) + + entries.append(.automaticVoiceDownloadHeader("AUTOMATIC AUDIO DOWNLOAD")) + entries.append(.automaticVoiceDownloadPrivateChats("Private Chats", data.automaticMediaDownloadSettings.categories.voice.privateChats)) + entries.append(.automaticVoiceDownloadGroupsAndChannels("Groups and Channels", data.automaticMediaDownloadSettings.categories.voice.groupsAndChannels)) + + entries.append(.automaticInstantVideoDownloadHeader("AUTOMATIC VIDEO MESSAGE DOWNLOAD")) + entries.append(.automaticInstantVideoDownloadPrivateChats("Private Chats", data.automaticMediaDownloadSettings.categories.instantVideo.privateChats)) + entries.append(.automaticInstantVideoDownloadGroupsAndChannels("Groups and Channels", data.automaticMediaDownloadSettings.categories.instantVideo.groupsAndChannels)) + + /*entries.append(.automaticGifDownloadHeader("AUTOMATIC GIF DOWNLOAD")) + entries.append(.automaticGifDownloadPrivateChats("Private Chats", data.automaticMediaDownloadSettings.categories.gif.privateChats)) + entries.append(.automaticGifDownloadGroupsAndChannels("Groups and Channels", data.automaticMediaDownloadSettings.categories.gif.groupsAndChannels))*/ + + entries.append(.voiceCallsHeader("VOICE CALLS")) + entries.append(.useLessVoiceData("Use Less Data", stringForUseLessDataSetting(data.voiceCallSettings))) + + entries.append(.otherHeader("OTHER")) + entries.append(.saveIncomingPhotos("Save Incoming Photos", data.automaticMediaDownloadSettings.saveIncomingPhotos)) + entries.append(.saveEditedPhotos("Save Edited Photos", data.generatedMediaStoreSettings.storeEditedPhotos)) + + return entries +} + +func dataAndStorageController(account: Account) -> ViewController { + let initialState = DataAndStorageControllerState() + + let statePromise = ValuePromise(initialState, ignoreRepeated: true) + let stateValue = Atomic(value: initialState) + let updateState: ((DataAndStorageControllerState) -> DataAndStorageControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var pushControllerImpl: ((ViewController) -> Void)? + + let actionsDisposable = DisposableSet() + + let dataAndStorageDataPromise = Promise() + dataAndStorageDataPromise.set(account.postbox.preferencesView(keys: [ApplicationSpecificPreferencesKeys.automaticMediaDownloadSettings, ApplicationSpecificPreferencesKeys.generatedMediaStoreSettings, ApplicationSpecificPreferencesKeys.voiceCallSettings]) + |> map { view -> DataAndStorageData in + let automaticMediaDownloadSettings: AutomaticMediaDownloadSettings + if let value = view.values[ApplicationSpecificPreferencesKeys.automaticMediaDownloadSettings] as? AutomaticMediaDownloadSettings { + automaticMediaDownloadSettings = value + } else { + automaticMediaDownloadSettings = AutomaticMediaDownloadSettings.defaultSettings + } + + let generatedMediaStoreSettings: GeneratedMediaStoreSettings + if let value = view.values[ApplicationSpecificPreferencesKeys.generatedMediaStoreSettings] as? GeneratedMediaStoreSettings { + generatedMediaStoreSettings = value + } else { + generatedMediaStoreSettings = GeneratedMediaStoreSettings.defaultSettings + } + + let voiceCallSettings: VoiceCallSettings + if let value = view.values[ApplicationSpecificPreferencesKeys.voiceCallSettings] as? VoiceCallSettings { + voiceCallSettings = value + } else { + voiceCallSettings = VoiceCallSettings.defaultSettings + } + + return DataAndStorageData(automaticMediaDownloadSettings: automaticMediaDownloadSettings, generatedMediaStoreSettings: generatedMediaStoreSettings, voiceCallSettings: voiceCallSettings) + }) + + let arguments = DataAndStorageControllerArguments(openStorageUsage: { + pushControllerImpl?(storageUsageController(account: account)) + }, openNetworkUsage: { + pushControllerImpl?(networkUsageStatsController(account: account)) + }, toggleAutomaticDownload: { category, peers, value in + let _ = updateMediaDownloadSettingsInteractively(postbox: account.postbox, { current in + switch category { + case .photo: + switch peers { + case .privateChats: + return current.withUpdatedCategories(current.categories.withUpdatedPhoto(current.categories.photo.withUpdatedPrivateChats(value))) + case .groupsAndChannels: + return current.withUpdatedCategories(current.categories.withUpdatedPhoto(current.categories.photo.withUpdatedGroupsAndChannels(value))) + } + case .voice: + switch peers { + case .privateChats: + return current.withUpdatedCategories(current.categories.withUpdatedVoice(current.categories.voice.withUpdatedPrivateChats(value))) + case .groupsAndChannels: + return current.withUpdatedCategories(current.categories.withUpdatedVoice(current.categories.voice.withUpdatedGroupsAndChannels(value))) + } + case .instantVideo: + switch peers { + case .privateChats: + return current.withUpdatedCategories(current.categories.withUpdatedInstantVideo(current.categories.instantVideo.withUpdatedPrivateChats(value))) + case .groupsAndChannels: + return current.withUpdatedCategories(current.categories.withUpdatedInstantVideo(current.categories.instantVideo.withUpdatedGroupsAndChannels(value))) + } + case .gif: + switch peers { + case .privateChats: + return current.withUpdatedCategories(current.categories.withUpdatedGif(current.categories.gif.withUpdatedPrivateChats(value))) + case .groupsAndChannels: + return current.withUpdatedCategories(current.categories.withUpdatedGif(current.categories.gif.withUpdatedGroupsAndChannels(value))) + } + } + }).start() + }, openVoiceUseLessData: { + pushControllerImpl?(voiceCallDataSavingController(account: account)) + }, toggleSaveIncomingPhotos: { value in + let _ = updateMediaDownloadSettingsInteractively(postbox: account.postbox, { current in + return current.withUpdatedSaveIncomingPhotos(value) + }).start() + }, toggleSaveEditedPhotos: { value in + let _ = updateGeneratedMediaStoreSettingsInteractively(postbox: account.postbox, { current in + return current.withUpdatedStoreEditedPhotos(value) + }).start() + }) + + let signal = combineLatest(statePromise.get(), dataAndStorageDataPromise.get()) |> deliverOnMainQueue + |> map { state, dataAndStorageData -> (ItemListControllerState, (ItemListNodeState, DataAndStorageEntry.ItemGenerationArguments)) in + + let controllerState = ItemListControllerState(title: .text("Data and Storage"), leftNavigationButton: nil, rightNavigationButton: nil, animateChanges: false) + let listState = ItemListNodeState(entries: dataAndStorageControllerEntries(state: state, data: dataAndStorageData), style: .blocks, 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) + + pushControllerImpl = { [weak controller] c in + if let controller = controller { + (controller.navigationController as? NavigationController)?.pushViewController(c) + } + } + + return controller +} diff --git a/TelegramUI/DebugAccountsController.swift b/TelegramUI/DebugAccountsController.swift index d12443a430..1d51406874 100644 --- a/TelegramUI/DebugAccountsController.swift +++ b/TelegramUI/DebugAccountsController.swift @@ -113,7 +113,7 @@ public func debugAccountsController(account: Account, accountManager: AccountMan let signal = accountManager.accountRecords() |> map { view -> (ItemListControllerState, (ItemListNodeState, DebugAccountsControllerEntry.ItemGenerationArguments)) in - let controllerState = ItemListControllerState(title: "Accounts", leftNavigationButton: nil, rightNavigationButton: nil) + let controllerState = ItemListControllerState(title: .text("Accounts"), leftNavigationButton: nil, rightNavigationButton: nil) let listState = ItemListNodeState(entries: debugAccountsControllerEntries(view: view), style: .blocks) return (controllerState, (listState, arguments)) diff --git a/TelegramUI/DebugController.swift b/TelegramUI/DebugController.swift index 2df44c31ce..7ee47e4449 100644 --- a/TelegramUI/DebugController.swift +++ b/TelegramUI/DebugController.swift @@ -106,7 +106,7 @@ public func debugController(account: Account, accountManager: AccountManager) -> let signal = Signal.single(Void()) |> map { _ -> (ItemListControllerState, (ItemListNodeState, DebugControllerEntry.ItemGenerationArguments)) in - let controllerState = ItemListControllerState(title: "Debug", leftNavigationButton: nil, rightNavigationButton: nil) + let controllerState = ItemListControllerState(title: .text("Debug"), leftNavigationButton: nil, rightNavigationButton: nil) let listState = ItemListNodeState(entries: debugControllerEntries(), style: .blocks) return (controllerState, (listState, arguments)) diff --git a/TelegramUI/DeclareEncodables.swift b/TelegramUI/DeclareEncodables.swift index 368c76d513..47c8048457 100644 --- a/TelegramUI/DeclareEncodables.swift +++ b/TelegramUI/DeclareEncodables.swift @@ -6,6 +6,9 @@ private var telegramUIDeclaredEncodables: Void = { declareEncodable(VideoLibraryMediaResource.self, f: { VideoLibraryMediaResource(decoder: $0) }) declareEncodable(LocalFileVideoMediaResource.self, f: { LocalFileVideoMediaResource(decoder: $0) }) declareEncodable(PhotoLibraryMediaResource.self, f: { PhotoLibraryMediaResource(decoder: $0) }) + declareEncodable(PresentationPasscodeSettings.self, f: { PresentationPasscodeSettings(decoder: $0) }) + declareEncodable(AutomaticMediaDownloadSettings.self, f: { AutomaticMediaDownloadSettings(decoder: $0) }) + declareEncodable(GeneratedMediaStoreSettings.self, f: { GeneratedMediaStoreSettings(decoder: $0) }) return }() diff --git a/TelegramUI/FFMpegAudioFrameDecoder.swift b/TelegramUI/FFMpegAudioFrameDecoder.swift index 63c677e687..0915ac9bdb 100644 --- a/TelegramUI/FFMpegAudioFrameDecoder.swift +++ b/TelegramUI/FFMpegAudioFrameDecoder.swift @@ -35,6 +35,10 @@ final class FFMpegAudioFrameDecoder: MediaTrackFrameDecoder { return nil } + func takeRemainingFrame() -> MediaTrackFrame? { + return nil + } + private func convertAudioFrame(_ frame: UnsafeMutablePointer, pts: CMTime, duration: CMTime) -> MediaTrackFrame? { guard let data = self.swrContext.resample(frame) else { return nil @@ -59,7 +63,7 @@ final class FFMpegAudioFrameDecoder: MediaTrackFrameDecoder { let resetDecoder = self.resetDecoderOnNextFrame self.resetDecoderOnNextFrame = false - return MediaTrackFrame(type: .audio, sampleBuffer: sampleBuffer!, resetDecoder: resetDecoder) + return MediaTrackFrame(type: .audio, sampleBuffer: sampleBuffer!, resetDecoder: resetDecoder, decoded: true) } func reset() { diff --git a/TelegramUI/FFMpegMediaFrameSource.swift b/TelegramUI/FFMpegMediaFrameSource.swift index bd98ebb492..aa6a74e76d 100644 --- a/TelegramUI/FFMpegMediaFrameSource.swift +++ b/TelegramUI/FFMpegMediaFrameSource.swift @@ -70,6 +70,8 @@ final class FFMpegMediaFrameSource: NSObject, MediaFrameSource { private let postbox: Postbox private let resource: MediaResource private let streamable: Bool + private let video: Bool + private let preferSoftwareDecoding: Bool private let taskQueue: ThreadTaskQueue private let thread: Thread @@ -88,11 +90,13 @@ final class FFMpegMediaFrameSource: NSObject, MediaFrameSource { } } - init(queue: Queue, postbox: Postbox, resource: MediaResource, streamable: Bool) { + init(queue: Queue, postbox: Postbox, resource: MediaResource, streamable: Bool, video: Bool, preferSoftwareDecoding: Bool) { self.queue = queue self.postbox = postbox self.resource = resource self.streamable = streamable + self.video = video + self.preferSoftwareDecoding = preferSoftwareDecoding self.taskQueue = ThreadTaskQueue() @@ -142,8 +146,11 @@ final class FFMpegMediaFrameSource: NSObject, MediaFrameSource { let resource = self.resource let queue = self.queue let streamable = self.streamable + let video = self.video + let preferSoftwareDecoding = self.preferSoftwareDecoding + self.performWithContext { [weak self] context in - context.initializeState(postbox: postbox, resource: resource, streamable: streamable) + context.initializeState(postbox: postbox, resource: resource, streamable: streamable, video: video, preferSoftwareDecoding: preferSoftwareDecoding) let (frames, endOfStream) = context.takeFrames(until: timestamp) @@ -186,9 +193,11 @@ final class FFMpegMediaFrameSource: NSObject, MediaFrameSource { let postbox = self.postbox let resource = self.resource let streamable = self.streamable + let video = self.video + let preferSoftwareDecoding = self.preferSoftwareDecoding self.performWithContext { [weak self] context in - context.initializeState(postbox: postbox, resource: resource, streamable: streamable) + context.initializeState(postbox: postbox, resource: resource, streamable: streamable, video: video, preferSoftwareDecoding: preferSoftwareDecoding) context.seek(timestamp: timestamp, completed: { [weak self] streamDescriptions, timestamp in queue.async { [weak self] in @@ -197,11 +206,11 @@ final class FFMpegMediaFrameSource: NSObject, MediaFrameSource { var videoBuffer: MediaTrackFrameBuffer? if let audio = streamDescriptions.audio { - audioBuffer = MediaTrackFrameBuffer(frameSource: strongSelf, decoder: audio.decoder, type: .audio, duration: audio.duration) + audioBuffer = MediaTrackFrameBuffer(frameSource: strongSelf, decoder: audio.decoder, type: .audio, duration: audio.duration, rotationAngle: 0.0) } if let video = streamDescriptions.video { - videoBuffer = MediaTrackFrameBuffer(frameSource: strongSelf, decoder: video.decoder, type: .video, duration: video.duration) + videoBuffer = MediaTrackFrameBuffer(frameSource: strongSelf, decoder: video.decoder, type: .video, duration: video.duration, rotationAngle: video.rotationAngle) } strongSelf.requestedFrameGenerationTimestamp = nil diff --git a/TelegramUI/FFMpegMediaFrameSourceContext.swift b/TelegramUI/FFMpegMediaFrameSourceContext.swift index 359702fdd8..dbd853f582 100644 --- a/TelegramUI/FFMpegMediaFrameSourceContext.swift +++ b/TelegramUI/FFMpegMediaFrameSourceContext.swift @@ -6,17 +6,19 @@ import TelegramUIPrivateModule import TelegramCore private struct StreamContext { - fileprivate let index: Int - fileprivate let codecContext: UnsafeMutablePointer? - fileprivate let fps: CMTime - fileprivate let timebase: CMTime - fileprivate let duration: CMTime - fileprivate let decoder: MediaTrackFrameDecoder + let index: Int + let codecContext: UnsafeMutablePointer? + let fps: CMTime + let timebase: CMTime + let duration: CMTime + let decoder: MediaTrackFrameDecoder + let rotationAngle: Double } struct FFMpegMediaFrameSourceDescription { let duration: CMTime let decoder: MediaTrackFrameDecoder + let rotationAngle: Double } struct FFMpegMediaFrameSourceDescriptionSet { @@ -57,7 +59,7 @@ private func readPacketCallback(userData: UnsafeMutableRawPointer?, buffer: Unsa if streamable { let data: Signal - data = postbox.mediaBox.resourceData(resource, size: resourceSize, in: context.readingOffset ..< (context.readingOffset + readCount), mode: .complete) + data = postbox.mediaBox.resourceData(resource, size: resourceSize, in: context.readingOffset ..< (context.readingOffset + readCount), tag: context.fetchTag, mode: .complete) let semaphore = DispatchSemaphore(value: 0) let _ = data.start(next: { data in if data.count == readCount { @@ -72,8 +74,16 @@ private func readPacketCallback(userData: UnsafeMutableRawPointer?, buffer: Unsa let semaphore = DispatchSemaphore(value: 0) let _ = data.start(next: { next in if next.complete { - if let data = try? Data(contentsOf: URL(fileURLWithPath: next.path), options: [.mappedIfSafe]) { - fetchedData = data.subdata(in: Range(range)) + let fd = open(next.path, O_RDONLY, S_IRUSR) + if fd >= 0 { + lseek(fd, off_t(range.lowerBound), SEEK_SET) + var data = Data(count: readCount) + data.withUnsafeMutableBytes { (bytes: UnsafeMutablePointer) -> Void in + let readBytes = read(fd, bytes, readCount) + assert(readBytes == readCount) + } + fetchedData = data + close(fd) } semaphore.signal() } @@ -93,7 +103,7 @@ private func readPacketCallback(userData: UnsafeMutableRawPointer?, buffer: Unsa private func seekCallback(userData: UnsafeMutableRawPointer?, offset: Int64, whence: Int32) -> Int64 { let context = Unmanaged.fromOpaque(userData!).takeUnretainedValue() - guard let postbox = context.postbox, let resource = context.resource, let streamable = context.streamable else { + guard let postbox = context.postbox, let resource = context.resource, let streamable = context.streamable, let fetchTag = context.fetchTag else { return 0 } @@ -114,10 +124,10 @@ private func seekCallback(userData: UnsafeMutableRawPointer?, offset: Int64, whe context.requestedCompleteFetch = false } else { if streamable { - context.fetchedDataDisposable.set(postbox.mediaBox.fetchedResourceData(resource, size: resourceSize, in: context.readingOffset ..< resourceSize).start()) + context.fetchedDataDisposable.set(postbox.mediaBox.fetchedResourceData(resource, size: resourceSize, in: context.readingOffset ..< resourceSize, tag: fetchTag).start()) } else if !context.requestedCompleteFetch { context.requestedCompleteFetch = true - context.fetchedDataDisposable.set(postbox.mediaBox.fetchedResource(resource).start()) + context.fetchedDataDisposable.set(postbox.mediaBox.fetchedResource(resource, tag: fetchTag).start()) } } } @@ -134,6 +144,7 @@ final class FFMpegMediaFrameSourceContext: NSObject { fileprivate var postbox: Postbox? fileprivate var resource: MediaResource? fileprivate var streamable: Bool? + fileprivate var fetchTag: TelegramMediaResourceFetchTag? private let ioBufferSize = 64 * 1024 fileprivate var readingOffset = 0 @@ -147,6 +158,8 @@ final class FFMpegMediaFrameSourceContext: NSObject { private var initializedState: InitializedState? private var packetQueue: [FFMpegPacket] = [] + private var preferSoftwareDecoding: Bool = false + init(thread: Thread) { self.thread = thread } @@ -157,7 +170,7 @@ final class FFMpegMediaFrameSourceContext: NSObject { fetchedDataDisposable.dispose() } - func initializeState(postbox: Postbox, resource: MediaResource, streamable: Bool) { + func initializeState(postbox: Postbox, resource: MediaResource, streamable: Bool, video: Bool, preferSoftwareDecoding: Bool) { if self.readingError || self.initializedState != nil { return } @@ -167,14 +180,20 @@ final class FFMpegMediaFrameSourceContext: NSObject { self.postbox = postbox self.resource = resource self.streamable = streamable + self.preferSoftwareDecoding = preferSoftwareDecoding + if video { + self.fetchTag = TelegramMediaResourceFetchTag(statsCategory: .video) + } else { + self.fetchTag = TelegramMediaResourceFetchTag(statsCategory: .audio) + } let resourceSize: Int = resource.size ?? 0 if streamable { - self.fetchedDataDisposable.set(postbox.mediaBox.fetchedResourceData(resource, size: resourceSize, in: 0 ..< resourceSize).start()) + self.fetchedDataDisposable.set(postbox.mediaBox.fetchedResourceData(resource, size: resourceSize, in: 0 ..< resourceSize, tag: self.fetchTag).start()) } else if !self.requestedCompleteFetch { self.requestedCompleteFetch = true - self.fetchedDataDisposable.set(postbox.mediaBox.fetchedResource(resource).start()) + self.fetchedDataDisposable.set(postbox.mediaBox.fetchedResource(resource, tag: self.fetchTag).start()) } var avFormatContextRef = avformat_alloc_context() @@ -211,7 +230,7 @@ final class FFMpegMediaFrameSourceContext: NSObject { let codecPar = avFormatContext.pointee.streams.advanced(by: streamIndex).pointee!.pointee.codecpar! - if false { + if self.preferSoftwareDecoding { if let codec = avcodec_find_decoder(codecPar.pointee.codec_id) { if let codecContext = avcodec_alloc_context3(codec) { if avcodec_parameters_to_context(codecContext, avFormatContext.pointee.streams[streamIndex]!.pointee.codecpar) >= 0 { @@ -220,7 +239,16 @@ final class FFMpegMediaFrameSourceContext: NSObject { let duration = CMTimeMake(avFormatContext.pointee.streams.advanced(by: streamIndex).pointee!.pointee.duration, timebase.timescale) - videoStream = StreamContext(index: streamIndex, codecContext: codecContext, fps: fps, timebase: timebase, duration: duration, decoder: FFMpegMediaVideoFrameDecoder(codecContext: codecContext)) + var rotationAngle: Double = 0.0 + if let rotationInfo = av_dict_get(avFormatContext.pointee.streams.advanced(by: streamIndex).pointee!.pointee.metadata, "rotate", nil, 0), let value = rotationInfo.pointee.value { + if strcmp(value, "0") != 0 { + if let angle = Double(String(cString: value)) { + rotationAngle = angle * Double.pi / 180.0 + } + } + } + + videoStream = StreamContext(index: streamIndex, codecContext: codecContext, fps: fps, timebase: timebase, duration: duration, decoder: FFMpegMediaVideoFrameDecoder(codecContext: codecContext), rotationAngle: rotationAngle) break } else { var codecContextRef: UnsafeMutablePointer? = codecContext @@ -238,7 +266,16 @@ final class FFMpegMediaFrameSourceContext: NSObject { let duration = CMTimeMake(avFormatContext.pointee.streams.advanced(by: streamIndex).pointee!.pointee.duration, timebase.timescale) - videoStream = StreamContext(index: streamIndex, codecContext: nil, fps: fps, timebase: timebase, duration: duration, decoder: FFMpegMediaPassthroughVideoFrameDecoder(videoFormat: videoFormat)) + var rotationAngle: Double = 0.0 + if let rotationInfo = av_dict_get(avFormatContext.pointee.streams.advanced(by: streamIndex).pointee!.pointee.metadata, "rotate", nil, 0), let value = rotationInfo.pointee.value { + if strcmp(value, "0") != 0 { + if let angle = Double(String(cString: value)) { + rotationAngle = angle * Double.pi / 180.0 + } + } + } + + videoStream = StreamContext(index: streamIndex, codecContext: nil, fps: fps, timebase: timebase, duration: duration, decoder: FFMpegMediaPassthroughVideoFrameDecoder(videoFormat: videoFormat, rotationAngle: rotationAngle), rotationAngle: rotationAngle) break } } @@ -254,7 +291,7 @@ final class FFMpegMediaFrameSourceContext: NSObject { let duration = CMTimeMake(avFormatContext.pointee.streams.advanced(by: streamIndex).pointee!.pointee.duration, timebase.timescale) - audioStream = StreamContext(index: streamIndex, codecContext: codecContext, fps: fps, timebase: timebase, duration: duration, decoder: FFMpegAudioFrameDecoder(codecContext: codecContext)) + audioStream = StreamContext(index: streamIndex, codecContext: codecContext, fps: fps, timebase: timebase, duration: duration, decoder: FFMpegAudioFrameDecoder(codecContext: codecContext), rotationAngle: 0.0) } else { var codecContextRef: UnsafeMutablePointer? = codecContext avcodec_free_context(&codecContextRef) @@ -267,6 +304,8 @@ final class FFMpegMediaFrameSourceContext: NSObject { } } + + self.initializedState = InitializedState(avIoContext: avIoContext, avFormatContext: avFormatContext, audioStream: audioStream, videoStream: videoStream) } @@ -406,11 +445,11 @@ final class FFMpegMediaFrameSourceContext: NSObject { var videoDescription: FFMpegMediaFrameSourceDescription? if let audioStream = initializedState.audioStream { - audioDescription = FFMpegMediaFrameSourceDescription(duration: audioStream.duration, decoder: audioStream.decoder) + audioDescription = FFMpegMediaFrameSourceDescription(duration: audioStream.duration, decoder: audioStream.decoder, rotationAngle: 0.0) } if let videoStream = initializedState.videoStream { - videoDescription = FFMpegMediaFrameSourceDescription(duration: videoStream.duration, decoder: videoStream.decoder) + videoDescription = FFMpegMediaFrameSourceDescription(duration: videoStream.duration, decoder: videoStream.decoder, rotationAngle: videoStream.rotationAngle) } var actualPts: CMTime = CMTimeMake(0, 1) diff --git a/TelegramUI/FFMpegMediaFrameSourceContextHelpers.swift b/TelegramUI/FFMpegMediaFrameSourceContextHelpers.swift index b58abd8cd3..9adfea9c24 100644 --- a/TelegramUI/FFMpegMediaFrameSourceContextHelpers.swift +++ b/TelegramUI/FFMpegMediaFrameSourceContextHelpers.swift @@ -4,7 +4,7 @@ import TelegramUIPrivateModule final class FFMpegMediaFrameSourceContextHelpers { static let registerFFMpegGlobals: Void = { - av_log_set_level(AV_LOG_DEBUG) + av_log_set_level(AV_LOG_QUIET) av_register_all() return }() diff --git a/TelegramUI/FFMpegMediaPassthroughVideoFrameDecoder.swift b/TelegramUI/FFMpegMediaPassthroughVideoFrameDecoder.swift index b7ee22a11d..8235b50135 100644 --- a/TelegramUI/FFMpegMediaPassthroughVideoFrameDecoder.swift +++ b/TelegramUI/FFMpegMediaPassthroughVideoFrameDecoder.swift @@ -2,10 +2,12 @@ import CoreMedia final class FFMpegMediaPassthroughVideoFrameDecoder: MediaTrackFrameDecoder { private let videoFormat: CMVideoFormatDescription + private let rotationAngle: Double private var resetDecoderOnNextFrame = true - init(videoFormat: CMVideoFormatDescription) { + init(videoFormat: CMVideoFormatDescription, rotationAngle: Double) { self.videoFormat = videoFormat + self.rotationAngle = rotationAngle } func decode(frame: MediaTrackDecodableFrame) -> MediaTrackFrame? { @@ -30,10 +32,15 @@ final class FFMpegMediaPassthroughVideoFrameDecoder: MediaTrackFrameDecoder { self.resetDecoderOnNextFrame = false let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer!, true)! as NSArray let dict = attachments[0] as! NSMutableDictionary + dict.setValue(kCFBooleanTrue as AnyObject, forKey: kCMSampleBufferAttachmentKey_ResetDecoderBeforeDecoding as NSString as String) } - return MediaTrackFrame(type: .video, sampleBuffer: sampleBuffer!, resetDecoder: resetDecoder) + return MediaTrackFrame(type: .video, sampleBuffer: sampleBuffer!, resetDecoder: resetDecoder, decoded: false, rotationAngle: self.rotationAngle) + } + + func takeRemainingFrame() -> MediaTrackFrame? { + return nil } func reset() { diff --git a/TelegramUI/FFMpegMediaVideoFrameDecoder.swift b/TelegramUI/FFMpegMediaVideoFrameDecoder.swift index d78b94f1ea..8da725a6b5 100644 --- a/TelegramUI/FFMpegMediaVideoFrameDecoder.swift +++ b/TelegramUI/FFMpegMediaVideoFrameDecoder.swift @@ -1,5 +1,8 @@ import TelegramUIPrivateModule import CoreMedia +import Accelerate + +private let bufferCount = 32 final class FFMpegMediaVideoFrameDecoder: MediaTrackFrameDecoder { private let codecContext: UnsafeMutablePointer @@ -7,9 +10,34 @@ final class FFMpegMediaVideoFrameDecoder: MediaTrackFrameDecoder { private let videoFrame: UnsafeMutablePointer private var resetDecoderOnNextFrame = true + private var pixelBufferPool: CVPixelBufferPool? + + private var delayedFrames: [MediaTrackFrame] = [] + init(codecContext: UnsafeMutablePointer) { self.codecContext = codecContext self.videoFrame = av_frame_alloc() + + /*var sourcePixelBufferOptions: [String: Any] = [:] + sourcePixelBufferOptions[kCVPixelBufferPixelFormatTypeKey as String] = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange as NSNumber + + sourcePixelBufferOptions[kCVPixelBufferWidthKey as String] = codecContext.pointee.width as NSNumber + sourcePixelBufferOptions[kCVPixelBufferHeightKey as String] = codecContext.pointee.height as NSNumber + sourcePixelBufferOptions[kCVPixelBufferBytesPerRowAlignmentKey as String] = 128 as NSNumber + sourcePixelBufferOptions[kCVPixelBufferPlaneAlignmentKey as String] = 128 as NSNumber + + let ioSurfaceProperties = NSMutableDictionary() + ioSurfaceProperties["IOSurfaceIsGlobal"] = true as NSNumber + + sourcePixelBufferOptions[kCVPixelBufferIOSurfacePropertiesKey as String] = ioSurfaceProperties + + var pixelBufferPoolOptions: [String: Any] = [:] + pixelBufferPoolOptions[kCVPixelBufferPoolMinimumBufferCountKey as String] = bufferCount as NSNumber + + var pixelBufferPool: CVPixelBufferPool? + CVPixelBufferPoolCreate(kCFAllocatorDefault, pixelBufferPoolOptions as CFDictionary, sourcePixelBufferOptions as CFDictionary, &pixelBufferPool) + + self.pixelBufferPool = pixelBufferPool*/ } deinit { @@ -19,20 +47,160 @@ final class FFMpegMediaVideoFrameDecoder: MediaTrackFrameDecoder { avcodec_free_context(&codecContextRef) } + func decodeInternal(frame: MediaTrackDecodableFrame) { + + } + func decode(frame: MediaTrackDecodableFrame) -> MediaTrackFrame? { + return self.decode(frame: frame, ptsOffset: nil) + } + + func decode(frame: MediaTrackDecodableFrame, ptsOffset: CMTime?) -> MediaTrackFrame? { var status = avcodec_send_packet(self.codecContext, frame.packet) if status == 0 { status = avcodec_receive_frame(self.codecContext, self.videoFrame) if status == 0 { - return convertVideoFrame(self.videoFrame, pts: frame.pts, duration: frame.duration) + var pts = CMTimeMake(self.videoFrame.pointee.pts, frame.pts.timescale) + if let ptsOffset = ptsOffset { + pts = CMTimeAdd(pts, ptsOffset) + } + return convertVideoFrame(self.videoFrame, pts: pts, dts: pts, duration: frame.duration) } } return nil } - private func convertVideoFrame(_ frame: UnsafeMutablePointer, pts: CMTime, duration: CMTime) -> MediaTrackFrame? { - return nil + func takeRemainingFrame() -> MediaTrackFrame? { + if !self.delayedFrames.isEmpty { + var minFrameIndex = 0 + var minPosition = self.delayedFrames[0].position + for i in 1 ..< self.delayedFrames.count { + if CMTimeCompare(self.delayedFrames[i].position, minPosition) < 0 { + minFrameIndex = i + minPosition = self.delayedFrames[i].position + } + } + return self.delayedFrames.remove(at: minFrameIndex) + } else { + return nil + } + } + + private func convertVideoFrame(_ frame: UnsafeMutablePointer, pts: CMTime, dts: CMTime, duration: CMTime) -> MediaTrackFrame? { + if frame.pointee.data.0 == nil { + return nil + } + if frame.pointee.linesize.1 != frame.pointee.linesize.2 { + return nil + } + + var pixelBufferRef: CVPixelBuffer? + if let pixelBufferPool = self.pixelBufferPool { + let auxAttributes: [String: Any] = [kCVPixelBufferPoolAllocationThresholdKey as String: bufferCount as NSNumber]; + let err = CVPixelBufferPoolCreatePixelBufferWithAuxAttributes(kCFAllocatorDefault, pixelBufferPool, auxAttributes as CFDictionary, &pixelBufferRef) + if err == kCVReturnWouldExceedAllocationThreshold { + print("kCVReturnWouldExceedAllocationThreshold, dropping frame") + return nil + } + } else { + let ioSurfaceProperties = NSMutableDictionary() + ioSurfaceProperties["IOSurfaceIsGlobal"] = true as NSNumber + + var options: [String: Any] = [kCVPixelBufferBytesPerRowAlignmentKey as String: frame.pointee.linesize.0 as NSNumber] + if #available(iOSApplicationExtension 9.0, *) { + options[kCVPixelBufferOpenGLESTextureCacheCompatibilityKey as String] = true as NSNumber + } + options[kCVPixelBufferIOSurfacePropertiesKey as String] = ioSurfaceProperties + + CVPixelBufferCreate(kCFAllocatorDefault, + Int(frame.pointee.width), + Int(frame.pointee.height), + kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + options as CFDictionary, + &pixelBufferRef) + } + + guard let pixelBuffer = pixelBufferRef else { + return nil + } + + let srcPlaneSize = Int(frame.pointee.linesize.1) * Int(frame.pointee.height / 2) + let dstPlaneSize = srcPlaneSize * 2 + + let dstPlane = malloc(dstPlaneSize)!.assumingMemoryBound(to: UInt8.self) + defer { + free(dstPlane) + } + + for i in 0 ..< srcPlaneSize { + dstPlane[2 * i] = frame.pointee.data.1![i] + dstPlane[2 * i + 1] = frame.pointee.data.2![i] + } + + CVPixelBufferLockBaseAddress(pixelBuffer, []) + + let bytePerRowY = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0) + + let bytesPerRowUV = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1) + + var base = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0) + memcpy(base, frame.pointee.data.0!, bytePerRowY * Int(frame.pointee.height)) + + base = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1) + memcpy(base, dstPlane, bytesPerRowUV * Int(frame.pointee.height) / 2) + + CVPixelBufferUnlockBaseAddress(pixelBuffer, []) + + var formatRef: CMVideoFormatDescription? + let formatStatus = CMVideoFormatDescriptionCreateForImageBuffer(kCFAllocatorDefault, pixelBuffer, &formatRef) + + guard let format = formatRef, formatStatus == 0 else { + return nil + } + + var timingInfo = CMSampleTimingInfo(duration: duration, presentationTimeStamp: pts, decodeTimeStamp: pts) + var sampleBuffer: CMSampleBuffer? + + guard CMSampleBufferCreateReadyWithImageBuffer(kCFAllocatorDefault, pixelBuffer, format, &timingInfo, &sampleBuffer) == noErr else { + return nil + } + + let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer!, true)! as NSArray + let dict = attachments[0] as! NSMutableDictionary + + let resetDecoder = self.resetDecoderOnNextFrame + if self.resetDecoderOnNextFrame { + self.resetDecoderOnNextFrame = false + //dict.setValue(kCFBooleanTrue as AnyObject, forKey: kCMSampleBufferAttachmentKey_ResetDecoderBeforeDecoding as NSString as String) + } + + dict.setValue(kCFBooleanTrue as AnyObject, forKey: kCMSampleAttachmentKey_DisplayImmediately as NSString as String) + + let decodedFrame = MediaTrackFrame(type: .video, sampleBuffer: sampleBuffer!, resetDecoder: resetDecoder, decoded: true) + + self.delayedFrames.append(decodedFrame) + + if self.delayedFrames.count >= 1 { + var minFrameIndex = 0 + var minPosition = self.delayedFrames[0].position + for i in 1 ..< self.delayedFrames.count { + if CMTimeCompare(self.delayedFrames[i].position, minPosition) < 0 { + minFrameIndex = i + minPosition = self.delayedFrames[i].position + } + } + if minFrameIndex != 0 { + assert(true) + } + return self.delayedFrames.remove(at: minFrameIndex) + } else { + return nil + } + } + + func decodeImage() { + } func reset() { diff --git a/TelegramUI/FeaturedStickerPacksController.swift b/TelegramUI/FeaturedStickerPacksController.swift index 97f013a44c..2a8d967d55 100644 --- a/TelegramUI/FeaturedStickerPacksController.swift +++ b/TelegramUI/FeaturedStickerPacksController.swift @@ -189,7 +189,7 @@ public func featuredStickerPacksController(account: Account) -> ViewController { let previous = previousPackCount previousPackCount = packCount - let controllerState = ItemListControllerState(title: "Trending Stickers", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) + let controllerState = ItemListControllerState(title: .text("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)) diff --git a/TelegramUI/FileResources.swift b/TelegramUI/FileResources.swift index 52f9915c1c..8b09e12db2 100644 --- a/TelegramUI/FileResources.swift +++ b/TelegramUI/FileResources.swift @@ -3,8 +3,8 @@ import Postbox import SwiftSignalKit import TelegramCore -func fileInteractiveFetched(account: Account, file: TelegramMediaFile) -> Signal { - return account.postbox.mediaBox.fetchedResource(file.resource) +func fileInteractiveFetched(account: Account, file: TelegramMediaFile) -> Signal { + return account.postbox.mediaBox.fetchedResource(file.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .file)) } func fileCancelInteractiveFetch(account: Account, file: TelegramMediaFile) { diff --git a/TelegramUI/GalleryController.swift b/TelegramUI/GalleryController.swift index e018a36844..a590abee91 100644 --- a/TelegramUI/GalleryController.swift +++ b/TelegramUI/GalleryController.swift @@ -17,7 +17,7 @@ private func tagsForMessage(_ message: Message) -> MessageTags? { return .PhotoOrVideo } } else if file.isVoice { - return .Voice + return .VoiceOrInstantVideo } else if file.isSticker { return nil } else { @@ -148,19 +148,23 @@ class GalleryController: ViewController { private let centralItemTitle = Promise() private let centralItemTitleView = Promise() private let centralItemNavigationStyle = Promise() - private let centralItemAttributesDisposable = DisposableSet() + private let centralItemFooterContentNode = Promise() + private let centralItemAttributesDisposable = DisposableSet(); private let _hiddenMedia = Promise<(MessageId, Media)?>(nil) var hiddenMedia: Signal<(MessageId, Media)?, NoError> { return self._hiddenMedia.get() } - init(account: Account, messageId: MessageId) { + private let replaceRootController: (ViewController, ValuePromise?) -> Void + + init(account: Account, messageId: MessageId, replaceRootController: @escaping (ViewController, ValuePromise?) -> Void) { self.account = account + self.replaceRootController = replaceRootController super.init() - self.navigationBar.backgroundColor = UIColor(white: 0.0, alpha: 0.5) + self.navigationBar.backgroundColor = UIColor(white: 0.0, alpha: 0.6) self.navigationBar.stripeColor = UIColor.clear self.navigationBar.foregroundColor = UIColor.white self.navigationBar.accentColor = UIColor.white @@ -222,6 +226,12 @@ class GalleryController: ViewController { self?.navigationItem.titleView = titleView })) + self.centralItemAttributesDisposable.add(self.centralItemFooterContentNode.get().start(next: { [weak self] footerContentNode in + self?.galleryNode.updatePresentationState({ + $0.withUpdatedFooterContentNode(footerContentNode) + }, transition: .immediate) + })) + self.centralItemAttributesDisposable.add(self.centralItemNavigationStyle.get().start(next: { [weak self] style in if let strongSelf = self { switch style { @@ -256,6 +266,10 @@ class GalleryController: ViewController { } @objc func donePressed() { + self.dismiss(forceAway: false) + } + + private func dismiss(forceAway: Bool) { var animatedOutNode = true var animatedOutInterface = false @@ -268,7 +282,7 @@ class GalleryController: ViewController { if let centralItemNode = self.galleryNode.pager.centralItemNode(), let presentationArguments = self.presentationArguments as? GalleryControllerPresentationArguments { if case let .MessageEntry(message, _, _, _) = self.entries[centralItemNode.index] { - if let media = mediaForMessage(message: message), let transitionArguments = presentationArguments.transitionArguments(message.id, media) { + if let media = mediaForMessage(message: message), let transitionArguments = presentationArguments.transitionArguments(message.id, media), !forceAway { animatedOutNode = false centralItemNode.animateOut(to: transitionArguments.transitionNode, completion: { animatedOutNode = true @@ -285,7 +299,18 @@ class GalleryController: ViewController { } override func loadDisplayNode() { - self.displayNode = GalleryControllerNode() + let controllerInteraction = GalleryControllerInteraction(presentController: { [weak self] controller, arguments in + if let strongSelf = self { + strongSelf.present(controller, in: .window, with: arguments) + } + }, dismissController: { [weak self] in + self?.dismiss(forceAway: true) + }, replaceRootController: { [weak self] controller, ready in + if let strongSelf = self { + strongSelf.replaceRootController(controller, ready) + } + }) + self.displayNode = GalleryControllerNode(controllerInteraction: controllerInteraction) self.displayNodeDidLoad() self.galleryNode.statusBar = self.statusBar @@ -322,6 +347,7 @@ class GalleryController: ViewController { strongSelf.centralItemTitle.set(node.title()) strongSelf.centralItemTitleView.set(node.titleView()) strongSelf.centralItemNavigationStyle.set(node.navigationStyle()) + strongSelf.centralItemFooterContentNode.set(node.footerContent()) } } if strongSelf.didSetReady { @@ -341,6 +367,7 @@ class GalleryController: ViewController { self.centralItemTitle.set(centralItemNode.title()) self.centralItemTitleView.set(centralItemNode.titleView()) self.centralItemNavigationStyle.set(centralItemNode.navigationStyle()) + self.centralItemFooterContentNode.set(centralItemNode.footerContent()) if let media = mediaForMessage(message: message), let transitionArguments = presentationArguments.transitionArguments(message.id, media) { nodeAnimatesItself = true diff --git a/TelegramUI/GalleryControllerNode.swift b/TelegramUI/GalleryControllerNode.swift index beeba6d575..f71e8afe75 100644 --- a/TelegramUI/GalleryControllerNode.swift +++ b/TelegramUI/GalleryControllerNode.swift @@ -5,6 +5,7 @@ import Display class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { var statusBar: StatusBar? var navigationBar: NavigationBar? + let footerNode: GalleryFooterNode var transitionNodeForCentralItem: (() -> ASDisplayNode?)? var dismiss: (() -> Void)? @@ -13,6 +14,8 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { var scrollView: UIScrollView var pager: GalleryPagerNode + private var presentationState = GalleryControllerPresentationState() + var areControlsHidden = false var isBackgroundExtendedOverNavigationBar = true { didSet { @@ -22,11 +25,12 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { } } - override init() { + init(controllerInteraction: GalleryControllerInteraction) { self.backgroundNode = ASDisplayNode() self.backgroundNode.backgroundColor = UIColor.black self.scrollView = UIScrollView() self.pager = GalleryPagerNode() + self.footerNode = GalleryFooterNode(controllerInteraction: controllerInteraction) super.init(viewBlock: { return UITracingLayerView() @@ -39,6 +43,7 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { let alpha: CGFloat = strongSelf.areControlsHidden ? 0.0 : 1.0 strongSelf.navigationBar?.alpha = alpha strongSelf.statusBar?.alpha = alpha + strongSelf.footerNode.alpha = alpha }) } } @@ -55,6 +60,7 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { self.view.addSubview(self.scrollView) self.scrollView.addSubview(self.pager.view) + self.addSubnode(self.footerNode) } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { @@ -62,6 +68,9 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: self.isBackgroundExtendedOverNavigationBar ? 0.0 : navigationBarHeight), size: CGSize(width: layout.size.width, height: layout.size.height - (self.isBackgroundExtendedOverNavigationBar ? 0.0 : navigationBarHeight)))) + transition.updateFrame(node: self.footerNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + self.footerNode.updateLayout(layout, footerContentNode: self.presentationState.footerContentNode, transition: transition) + let previousContentHeight = self.scrollView.contentSize.height let previousVerticalOffset = self.scrollView.contentOffset.y @@ -82,10 +91,12 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { self.backgroundNode.backgroundColor = self.backgroundNode.backgroundColor?.withAlphaComponent(0.0) self.statusBar?.alpha = 0.0 self.navigationBar?.alpha = 0.0 + self.footerNode.alpha = 0.0 UIView.animate(withDuration: 0.2, animations: { self.backgroundNode.backgroundColor = self.backgroundNode.backgroundColor?.withAlphaComponent(1.0) self.statusBar?.alpha = 1.0 self.navigationBar?.alpha = 1.0 + self.footerNode.alpha = 1.0 }) if animateContent { @@ -107,6 +118,7 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { self.backgroundNode.backgroundColor = self.backgroundNode.backgroundColor?.withAlphaComponent(0.0) self.statusBar?.alpha = 0.0 self.navigationBar?.alpha = 0.0 + self.footerNode.alpha = 0.0 }, completion: { _ in interfaceAnimationCompleted = true intermediateCompletion() @@ -131,6 +143,7 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { if !self.areControlsHidden { self.statusBar?.alpha = transition self.navigationBar?.alpha = transition + self.footerNode.alpha = transition } } @@ -175,4 +188,11 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate { self.scrollView.setContentOffset(CGPoint(x: 0.0, y: self.scrollView.contentSize.height / 3.0), animated: true) } } + + func updatePresentationState(_ f: (GalleryControllerPresentationState) -> GalleryControllerPresentationState, transition: ContainedViewLayoutTransition) { + self.presentationState = f(self.presentationState) + if let (navigationBarHeight, layout) = self.containerLayout { + self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) + } + } } diff --git a/TelegramUI/GalleryControllerPresentationState.swift b/TelegramUI/GalleryControllerPresentationState.swift new file mode 100644 index 0000000000..9a99a73551 --- /dev/null +++ b/TelegramUI/GalleryControllerPresentationState.swift @@ -0,0 +1,17 @@ +import Foundation + +final class GalleryControllerPresentationState { + let footerContentNode: GalleryFooterContentNode? + + init() { + self.footerContentNode = nil + } + + init(footerContentNode: GalleryFooterContentNode?) { + self.footerContentNode = footerContentNode + } + + func withUpdatedFooterContentNode(_ footerContentNode: GalleryFooterContentNode?) -> GalleryControllerPresentationState { + return GalleryControllerPresentationState(footerContentNode: footerContentNode) + } +} diff --git a/TelegramUI/GalleryFooterContentNode.swift b/TelegramUI/GalleryFooterContentNode.swift new file mode 100644 index 0000000000..29261b00f2 --- /dev/null +++ b/TelegramUI/GalleryFooterContentNode.swift @@ -0,0 +1,25 @@ +import Foundation +import AsyncDisplayKit +import Display +import SwiftSignalKit + +final class GalleryControllerInteraction { + let presentController: (ViewController, ViewControllerPresentationArguments?) -> Void + let dismissController: () -> Void + let replaceRootController: (ViewController, ValuePromise?) -> Void + + init(presentController: @escaping (ViewController, ViewControllerPresentationArguments?) -> Void, dismissController: @escaping () -> Void, replaceRootController: @escaping (ViewController, ValuePromise?) -> Void) { + self.presentController = presentController + self.dismissController = dismissController + self.replaceRootController = replaceRootController + } +} + +open class GalleryFooterContentNode: ASDisplayNode { + var requestLayout: ((ContainedViewLayoutTransition) -> Void)? + var controllerInteraction: GalleryControllerInteraction? + + func updateLayout(width: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { + return 0.0 + } +} diff --git a/TelegramUI/GalleryFooterNode.swift b/TelegramUI/GalleryFooterNode.swift new file mode 100644 index 0000000000..1abb0d2683 --- /dev/null +++ b/TelegramUI/GalleryFooterNode.swift @@ -0,0 +1,65 @@ +import Foundation +import AsyncDisplayKit +import Display + +final class GalleryFooterNode: ASDisplayNode { + private let backgroundNode: ASDisplayNode + + private var currentFooterContentNode: GalleryFooterContentNode? + private var currentLayout: ContainerViewLayout? + + private let controllerInteraction: GalleryControllerInteraction + + init(controllerInteraction: GalleryControllerInteraction) { + self.controllerInteraction = controllerInteraction + + self.backgroundNode = ASDisplayNode() + self.backgroundNode.backgroundColor = UIColor(white: 0.0, alpha: 0.6) + + super.init() + + self.addSubnode(self.backgroundNode) + } + + func updateLayout(_ layout: ContainerViewLayout, footerContentNode: GalleryFooterContentNode?, transition: ContainedViewLayoutTransition) { + self.currentLayout = layout + + var removeCurrentFooterContentNode: GalleryFooterContentNode? + if self.currentFooterContentNode !== footerContentNode { + if let currentFooterContentNode = self.currentFooterContentNode { + currentFooterContentNode.requestLayout = nil + removeCurrentFooterContentNode = currentFooterContentNode + } + self.currentFooterContentNode = footerContentNode + if let footerContentNode = footerContentNode { + footerContentNode.controllerInteraction = self.controllerInteraction + footerContentNode.requestLayout = { [weak self] transition in + if let strongSelf = self, let currentLayout = strongSelf.currentLayout { + strongSelf.updateLayout(currentLayout, footerContentNode: strongSelf.currentFooterContentNode, transition: transition) + } + } + self.addSubnode(footerContentNode) + } + } + + if let removeCurrentFooterContentNode = removeCurrentFooterContentNode { + removeCurrentFooterContentNode.removeFromSupernode() + } + + var backgroundHeight: CGFloat = 0.0 + if let footerContentNode = self.currentFooterContentNode { + backgroundHeight = footerContentNode.updateLayout(width: layout.size.width, transition: transition) + transition.updateFrame(node: footerContentNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - backgroundHeight), size: CGSize(width: layout.size.width, height: backgroundHeight))) + } + + transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - backgroundHeight), size: CGSize(width: layout.size.width, height: backgroundHeight))) + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if !self.backgroundNode.frame.contains(point) { + return nil + } + let result = super.hitTest(point, with: event) + return result + } +} diff --git a/TelegramUI/GalleryItemNode.swift b/TelegramUI/GalleryItemNode.swift index 5583760b70..3491006c31 100644 --- a/TelegramUI/GalleryItemNode.swift +++ b/TelegramUI/GalleryItemNode.swift @@ -38,6 +38,10 @@ open class GalleryItemNode: ASDisplayNode { return .single(nil) } + open func footerContent() -> Signal { + return .single(nil) + } + open func navigationStyle() -> Signal { return .single(.dark) } diff --git a/TelegramUI/GalleryPagerNode.swift b/TelegramUI/GalleryPagerNode.swift index c16c4913a6..5ea20e2aa0 100644 --- a/TelegramUI/GalleryPagerNode.swift +++ b/TelegramUI/GalleryPagerNode.swift @@ -10,9 +10,10 @@ final class GalleryPagerNode: ASDisplayNode, UIScrollViewDelegate { private var items: [GalleryItem] = [] private var itemNodes: [GalleryItemNode] = [] + private var ignoreCentralItemIndexUpdate = false private var centralItemIndex: Int? { didSet { - if oldValue != self.centralItemIndex { + if oldValue != self.centralItemIndex && !self.ignoreCentralItemIndexUpdate { self.centralItemIndexUpdated(self.centralItemIndex) } } @@ -75,11 +76,19 @@ final class GalleryPagerNode: ASDisplayNode, UIScrollViewDelegate { } } - func replaceItems(_ items: [GalleryItem], centralItemIndex: Int?) { + func replaceItems(_ items: [GalleryItem], centralItemIndex: Int?, keepFirst: Bool = false) { + var keptItemNode: GalleryItemNode? for itemNode in self.itemNodes { - itemNode.removeFromSupernode() + if keepFirst && itemNode.index == 0 { + keptItemNode = itemNode + } else { + itemNode.removeFromSupernode() + } } self.itemNodes.removeAll() + if let keptItemNode = keptItemNode { + self.itemNodes.append(keptItemNode) + } if let centralItemIndex = centralItemIndex, centralItemIndex >= 0 && centralItemIndex < items.count { self.centralItemIndex = centralItemIndex } else { @@ -143,6 +152,8 @@ final class GalleryPagerNode: ASDisplayNode, UIScrollViewDelegate { resetOffsetToCentralItem = true } + var notifyCentralItemUpdated = false + if let centralItemIndex = self.centralItemIndex, let centralItemNode = self.visibleItemNode(at: centralItemIndex) { if centralItemIndex != 0 { if self.visibleItemNode(at: centralItemIndex - 1) == nil { @@ -182,7 +193,10 @@ final class GalleryPagerNode: ASDisplayNode, UIScrollViewDelegate { } } + self.ignoreCentralItemIndexUpdate = true self.centralItemIndex = centralItemCandidateNode.index + self.ignoreCentralItemIndexUpdate = false + notifyCentralItemUpdated = true if centralItemCandidateNode.index != 0 { if self.visibleItemNode(at: centralItemCandidateNode.index - 1) == nil { @@ -224,6 +238,10 @@ final class GalleryPagerNode: ASDisplayNode, UIScrollViewDelegate { itemNode.centralityUpdated(isCentral: itemNode.index == self.centralItemIndex) itemNode.visibilityUpdated(isVisible: self.scrollView.bounds.intersects(itemNode.frame)) } + + if notifyCentralItemUpdated { + self.centralItemIndexUpdated(self.centralItemIndex) + } } func scrollViewDidScroll(_ scrollView: UIScrollView) { diff --git a/TelegramUI/GeneratedMediaStoreSettings.swift b/TelegramUI/GeneratedMediaStoreSettings.swift new file mode 100644 index 0000000000..774dc5ba8d --- /dev/null +++ b/TelegramUI/GeneratedMediaStoreSettings.swift @@ -0,0 +1,53 @@ +import Foundation +import Postbox +import SwiftSignalKit + +public struct GeneratedMediaStoreSettings: PreferencesEntry, Equatable { + public let storeEditedPhotos: Bool + + public static var defaultSettings: GeneratedMediaStoreSettings { + return GeneratedMediaStoreSettings(storeEditedPhotos: true) + } + + init(storeEditedPhotos: Bool) { + self.storeEditedPhotos = storeEditedPhotos + } + + public init(decoder: Decoder) { + self.storeEditedPhotos = (decoder.decodeInt32ForKey("eph") as Int32) != 0 + } + + public func encode(_ encoder: Encoder) { + encoder.encodeInt32(self.storeEditedPhotos ? 1 : 0, forKey: "eph") + } + + public func isEqual(to: PreferencesEntry) -> Bool { + if let to = to as? GeneratedMediaStoreSettings { + return self == to + } else { + return false + } + } + + public static func ==(lhs: GeneratedMediaStoreSettings, rhs: GeneratedMediaStoreSettings) -> Bool { + return lhs.storeEditedPhotos == rhs.storeEditedPhotos + } + + func withUpdatedStoreEditedPhotos(_ storeEditedPhotos: Bool) -> GeneratedMediaStoreSettings { + return GeneratedMediaStoreSettings(storeEditedPhotos: storeEditedPhotos) + } +} + +func updateGeneratedMediaStoreSettingsInteractively(postbox: Postbox, _ f: @escaping (GeneratedMediaStoreSettings) -> GeneratedMediaStoreSettings) -> Signal { + return postbox.modify { modifier -> Void in + modifier.updatePreferencesEntry(key: ApplicationSpecificPreferencesKeys.generatedMediaStoreSettings, { entry in + let currentSettings: GeneratedMediaStoreSettings + if let entry = entry as? GeneratedMediaStoreSettings { + currentSettings = entry + } else { + currentSettings = GeneratedMediaStoreSettings.defaultSettings + } + return f(currentSettings) + }) + } +} diff --git a/TelegramUI/GridMessageItem.swift b/TelegramUI/GridMessageItem.swift index 66892fec33..6998a784b7 100644 --- a/TelegramUI/GridMessageItem.swift +++ b/TelegramUI/GridMessageItem.swift @@ -292,7 +292,7 @@ final class GridMessageItemNode: GridItemNode { case .Local: controllerInteraction.openMessage(messageId) case .Remote: - self.fetchDisposable.set(account.postbox.mediaBox.fetchedResource(file.resource).start()) + self.fetchDisposable.set(account.postbox.mediaBox.fetchedResource(file.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .file)).start()) } } } else { diff --git a/TelegramUI/GroupAdminsController.swift b/TelegramUI/GroupAdminsController.swift index d89c432043..2acba0ae6a 100644 --- a/TelegramUI/GroupAdminsController.swift +++ b/TelegramUI/GroupAdminsController.swift @@ -150,7 +150,7 @@ private enum GroupAdminsEntry: ItemListNodeEntry { case let .allAdminsInfo(text): 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 + return ItemListPeerItem(account: arguments.account, peer: peer, presence: presence, text: .presence, label: .none, 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) }) } @@ -361,7 +361,7 @@ public func groupAdminsController(account: Account, peerId: PeerId) -> ViewContr rightNavigationButton = ItemListNavigationButton(title: "", style: .activity, enabled: true, action: {}) } - let controllerState = ItemListControllerState(title: "Admins", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) + let controllerState = ItemListControllerState(title: .text("Admins"), leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) let listState = ItemListNodeState(entries: groupAdminsControllerEntries(account: account, view: view, state: state), style: .blocks, emptyStateItem: emptyStateItem, animateChanges: true) return (controllerState, (listState, arguments)) diff --git a/TelegramUI/GroupInfoController.swift b/TelegramUI/GroupInfoController.swift index 105e4eb50d..06b0c53b11 100644 --- a/TelegramUI/GroupInfoController.swift +++ b/TelegramUI/GroupInfoController.swift @@ -3,6 +3,7 @@ import Display import SwiftSignalKit import Postbox import TelegramCore +import TelegramLegacyComponents private let addMemberPlusIcon = UIImage(bundleImageName: "Peer Info/PeerItemPlusIcon")?.precomposed() @@ -10,6 +11,9 @@ private final class GroupInfoArguments { let account: Account let peerId: PeerId + let avatarAndNameInfoContext: ItemListAvatarAndNameInfoItemContext + let tapAvatarAction: () -> Void + let changeProfilePhoto: () -> Void let pushController: (ViewController) -> Void let presentController: (ViewController, ViewControllerPresentationArguments) -> Void let changeNotificationMuteSettings: () -> Void @@ -22,9 +26,12 @@ private final class GroupInfoArguments { let removePeer: (PeerId) -> Void let convertToSupergroup: () -> Void - init(account: Account, peerId: PeerId, pushController: @escaping (ViewController) -> Void, presentController: @escaping (ViewController, ViewControllerPresentationArguments) -> Void, changeNotificationMuteSettings: @escaping () -> Void, openSharedMedia: @escaping () -> Void, openAdminManagement: @escaping () -> Void, updateEditingName: @escaping (ItemListAvatarAndNameInfoItemName) -> Void, updateEditingDescriptionText: @escaping (String) -> Void, setPeerIdWithRevealedOptions: @escaping (PeerId?, PeerId?) -> Void, addMember: @escaping () -> Void, removePeer: @escaping (PeerId) -> Void, convertToSupergroup: @escaping () -> Void) { + init(account: Account, peerId: PeerId, avatarAndNameInfoContext: ItemListAvatarAndNameInfoItemContext, tapAvatarAction: @escaping () -> Void, changeProfilePhoto: @escaping () -> Void, pushController: @escaping (ViewController) -> Void, presentController: @escaping (ViewController, ViewControllerPresentationArguments) -> Void, changeNotificationMuteSettings: @escaping () -> Void, openSharedMedia: @escaping () -> Void, openAdminManagement: @escaping () -> Void, updateEditingName: @escaping (ItemListAvatarAndNameInfoItemName) -> Void, updateEditingDescriptionText: @escaping (String) -> Void, setPeerIdWithRevealedOptions: @escaping (PeerId?, PeerId?) -> Void, addMember: @escaping () -> Void, removePeer: @escaping (PeerId) -> Void, convertToSupergroup: @escaping () -> Void) { self.account = account self.peerId = peerId + self.avatarAndNameInfoContext = avatarAndNameInfoContext + self.tapAvatarAction = tapAvatarAction + self.changeProfilePhoto = changeProfilePhoto self.pushController = pushController self.presentController = presentController self.changeNotificationMuteSettings = changeNotificationMuteSettings @@ -86,7 +93,7 @@ private enum GroupEntryStableId: Hashable, Equatable { } private enum GroupInfoEntry: ItemListNodeEntry { - case info(peer: Peer?, cachedData: CachedPeerData?, state: ItemListAvatarAndNameInfoItemState) + case info(peer: Peer?, cachedData: CachedPeerData?, state: ItemListAvatarAndNameInfoItemState, updatingAvatar: TelegramMediaImageRepresentation?) case setGroupPhoto case about(String) case link(String) @@ -124,8 +131,8 @@ private enum GroupInfoEntry: ItemListNodeEntry { static func ==(lhs: GroupInfoEntry, rhs: GroupInfoEntry) -> Bool { switch lhs { - case let .info(lhsPeer, lhsCachedData, lhsState): - if case let .info(rhsPeer, rhsCachedData, rhsState) = rhs { + case let .info(lhsPeer, lhsCachedData, lhsState, lhsUpdatingAvatar): + if case let .info(rhsPeer, rhsCachedData, rhsState, rhsUpdatingAvatar) = rhs { if let lhsPeer = lhsPeer, let rhsPeer = rhsPeer { if !lhsPeer.isEqual(rhsPeer) { return false @@ -143,6 +150,9 @@ private enum GroupInfoEntry: ItemListNodeEntry { if lhsState != rhsState { return false } + if lhsUpdatingAvatar != rhsUpdatingAvatar { + return false + } return true } else { return false @@ -294,12 +304,15 @@ private enum GroupInfoEntry: ItemListNodeEntry { func item(_ arguments: GroupInfoArguments) -> ListViewItem { switch self { - case let .info(peer, cachedData, state): + case let .info(peer, cachedData, state, updatingAvatar): return ItemListAvatarAndNameInfoItem(account: arguments.account, peer: peer, presence: nil, cachedData: cachedData, state: state, sectionId: self.section, style: .blocks, editingNameUpdated: { editingName in arguments.updateEditingName(editingName) - }) + }, avatarTapped: { + arguments.tapAvatarAction() + }, context: arguments.avatarAndNameInfoContext, updatingImage: updatingAvatar) case .setGroupPhoto: return ItemListActionItem(title: "Set Group Photo", kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { + arguments.changeProfilePhoto() }) case let .about(text): return ItemListMultilineTextItem(text: text, sectionId: self.section, style: .blocks) @@ -354,7 +367,7 @@ private enum GroupInfoEntry: ItemListNodeEntry { case .member: label = nil } - return ItemListPeerItem(account: arguments.account, peer: peer, presence: presence, text: .presence, label: label, editing: editing, switchValue: nil, enabled: enabled, sectionId: self.section, action: { + return ItemListPeerItem(account: arguments.account, peer: peer, presence: presence, text: .presence, label: label == nil ? .none : .text(label!), editing: editing, switchValue: nil, enabled: enabled, sectionId: self.section, action: { if let infoController = peerInfoController(account: arguments.account, peer: peer) { arguments.pushController(infoController) } @@ -397,6 +410,7 @@ private struct TemporaryParticipant: Equatable { } private struct GroupInfoState: Equatable { + let updatingAvatar: TelegramMediaImageRepresentation? let editingState: GroupInfoEditingState? let updatingName: ItemListAvatarAndNameInfoItemName? let peerIdWithRevealedOptions: PeerId? @@ -408,6 +422,9 @@ private struct GroupInfoState: Equatable { let savingData: Bool static func ==(lhs: GroupInfoState, rhs: GroupInfoState) -> Bool { + if lhs.updatingAvatar != rhs.updatingAvatar { + return false + } if lhs.editingState != rhs.editingState { return false } @@ -432,32 +449,36 @@ private struct GroupInfoState: Equatable { return true } + func withUpdatedUpdatingAvatar(_ updatingAvatar: TelegramMediaImageRepresentation?) -> GroupInfoState { + return GroupInfoState(updatingAvatar: updatingAvatar, editingState: self.editingState, updatingName: self.updatingName, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions, temporaryParticipants: self.temporaryParticipants, successfullyAddedParticipantIds: self.successfullyAddedParticipantIds, removingParticipantIds: self.removingParticipantIds, savingData: self.savingData) + } + func withUpdatedEditingState(_ editingState: GroupInfoEditingState?) -> GroupInfoState { - return GroupInfoState(editingState: editingState, updatingName: self.updatingName, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions, temporaryParticipants: self.temporaryParticipants, successfullyAddedParticipantIds: self.successfullyAddedParticipantIds, removingParticipantIds: self.removingParticipantIds, savingData: self.savingData) + return GroupInfoState(updatingAvatar: self.updatingAvatar, editingState: editingState, updatingName: self.updatingName, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions, temporaryParticipants: self.temporaryParticipants, successfullyAddedParticipantIds: self.successfullyAddedParticipantIds, removingParticipantIds: self.removingParticipantIds, savingData: self.savingData) } func withUpdatedUpdatingName(_ updatingName: ItemListAvatarAndNameInfoItemName?) -> GroupInfoState { - return GroupInfoState(editingState: self.editingState, updatingName: updatingName, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions, temporaryParticipants: self.temporaryParticipants, successfullyAddedParticipantIds: self.successfullyAddedParticipantIds, removingParticipantIds: self.removingParticipantIds, savingData: self.savingData) + return GroupInfoState(updatingAvatar: self.updatingAvatar, editingState: self.editingState, updatingName: updatingName, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions, temporaryParticipants: self.temporaryParticipants, successfullyAddedParticipantIds: self.successfullyAddedParticipantIds, removingParticipantIds: self.removingParticipantIds, savingData: self.savingData) } func withUpdatedPeerIdWithRevealedOptions(_ peerIdWithRevealedOptions: PeerId?) -> GroupInfoState { - return GroupInfoState(editingState: self.editingState, updatingName: self.updatingName, peerIdWithRevealedOptions: peerIdWithRevealedOptions, temporaryParticipants: self.temporaryParticipants, successfullyAddedParticipantIds: self.successfullyAddedParticipantIds, removingParticipantIds: self.removingParticipantIds, savingData: self.savingData) + return GroupInfoState(updatingAvatar: self.updatingAvatar, editingState: self.editingState, updatingName: self.updatingName, peerIdWithRevealedOptions: peerIdWithRevealedOptions, temporaryParticipants: self.temporaryParticipants, successfullyAddedParticipantIds: self.successfullyAddedParticipantIds, removingParticipantIds: self.removingParticipantIds, savingData: self.savingData) } func withUpdatedTemporaryParticipants(_ temporaryParticipants: [TemporaryParticipant]) -> GroupInfoState { - return GroupInfoState(editingState: self.editingState, updatingName: self.updatingName, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions, temporaryParticipants: temporaryParticipants, successfullyAddedParticipantIds: self.successfullyAddedParticipantIds, removingParticipantIds: self.removingParticipantIds, savingData: self.savingData) + return GroupInfoState(updatingAvatar: self.updatingAvatar, editingState: self.editingState, updatingName: self.updatingName, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions, temporaryParticipants: temporaryParticipants, successfullyAddedParticipantIds: self.successfullyAddedParticipantIds, removingParticipantIds: self.removingParticipantIds, savingData: self.savingData) } func withUpdatedSuccessfullyAddedParticipantIds(_ successfullyAddedParticipantIds: Set) -> GroupInfoState { - return GroupInfoState(editingState: self.editingState, updatingName: self.updatingName, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions, temporaryParticipants: self.temporaryParticipants, successfullyAddedParticipantIds: successfullyAddedParticipantIds, removingParticipantIds: self.removingParticipantIds, savingData: self.savingData) + return GroupInfoState(updatingAvatar: self.updatingAvatar, editingState: self.editingState, updatingName: self.updatingName, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions, temporaryParticipants: self.temporaryParticipants, successfullyAddedParticipantIds: successfullyAddedParticipantIds, removingParticipantIds: self.removingParticipantIds, savingData: self.savingData) } func withUpdatedRemovingParticipantIds(_ removingParticipantIds: Set) -> GroupInfoState { - return GroupInfoState(editingState: self.editingState, updatingName: self.updatingName, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions, temporaryParticipants: self.temporaryParticipants, successfullyAddedParticipantIds: self.successfullyAddedParticipantIds, removingParticipantIds: removingParticipantIds, savingData: self.savingData) + return GroupInfoState(updatingAvatar: self.updatingAvatar, editingState: self.editingState, updatingName: self.updatingName, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions, temporaryParticipants: self.temporaryParticipants, successfullyAddedParticipantIds: self.successfullyAddedParticipantIds, removingParticipantIds: removingParticipantIds, savingData: self.savingData) } func withUpdatedSavingData(_ savingData: Bool) -> GroupInfoState { - return GroupInfoState(editingState: self.editingState, updatingName: self.updatingName, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions, temporaryParticipants: self.temporaryParticipants, successfullyAddedParticipantIds: self.successfullyAddedParticipantIds, removingParticipantIds: self.removingParticipantIds, savingData: savingData) + return GroupInfoState(updatingAvatar: self.updatingAvatar, editingState: self.editingState, updatingName: self.updatingName, peerIdWithRevealedOptions: self.peerIdWithRevealedOptions, temporaryParticipants: self.temporaryParticipants, successfullyAddedParticipantIds: self.successfullyAddedParticipantIds, removingParticipantIds: self.removingParticipantIds, savingData: savingData) } } @@ -496,7 +517,7 @@ private func groupInfoEntries(account: Account, view: PeerView, state: GroupInfo var entries: [GroupInfoEntry] = [] if let peer = peerViewMainPeer(view) { let infoState = ItemListAvatarAndNameInfoItemState(editingName: state.editingState?.editingName, updatingName: state.updatingName) - entries.append(.info(peer: peer, cachedData: view.cachedData, state: infoState)) + entries.append(.info(peer: peer, cachedData: view.cachedData, state: infoState, updatingAvatar: state.updatingAvatar)) } var highlightAdmins = false @@ -849,14 +870,14 @@ private func valuesRequiringUpdate(state: GroupInfoState, view: PeerView) -> (ti } public func groupInfoController(account: Account, peerId: PeerId) -> ViewController { - let statePromise = ValuePromise(GroupInfoState(editingState: nil, updatingName: nil, peerIdWithRevealedOptions: nil, temporaryParticipants: [], successfullyAddedParticipantIds: Set(), removingParticipantIds: Set(), savingData: false), ignoreRepeated: true) - let stateValue = Atomic(value: GroupInfoState(editingState: nil, updatingName: nil, peerIdWithRevealedOptions: nil, temporaryParticipants: [], successfullyAddedParticipantIds: Set(), removingParticipantIds: Set(), savingData: false)) + let statePromise = ValuePromise(GroupInfoState(updatingAvatar: nil, editingState: nil, updatingName: nil, peerIdWithRevealedOptions: nil, temporaryParticipants: [], successfullyAddedParticipantIds: Set(), removingParticipantIds: Set(), savingData: false), ignoreRepeated: true) + let stateValue = Atomic(value: GroupInfoState(updatingAvatar: nil, editingState: nil, updatingName: nil, peerIdWithRevealedOptions: nil, temporaryParticipants: [], successfullyAddedParticipantIds: Set(), removingParticipantIds: Set(), savingData: false)) let updateState: ((GroupInfoState) -> GroupInfoState) -> Void = { f in statePromise.set(stateValue.modify { f($0) }) } var pushControllerImpl: ((ViewController) -> Void)? - var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments) -> Void)? + var presentControllerImpl: ((ViewController, Any?) -> Void)? let actionsDisposable = DisposableSet() @@ -879,7 +900,78 @@ public func groupInfoController(account: Account, peerId: PeerId) -> ViewControl let changeMuteSettingsDisposable = MetaDisposable() actionsDisposable.add(changeMuteSettingsDisposable) - let arguments = GroupInfoArguments(account: account, peerId: peerId, pushController: { controller in + let hiddenAvatarRepresentationDisposable = MetaDisposable() + actionsDisposable.add(hiddenAvatarRepresentationDisposable) + + let updateAvatarDisposable = MetaDisposable() + actionsDisposable.add(updateAvatarDisposable) + let currentAvatarMixin = Atomic(value: nil) + + var avatarGalleryTransitionArguments: ((AvatarGalleryEntry) -> GalleryTransitionArguments?)? + let avatarAndNameInfoContext = ItemListAvatarAndNameInfoItemContext() + var updateHiddenAvatarImpl: (() -> Void)? + + let arguments = GroupInfoArguments(account: account, peerId: peerId, avatarAndNameInfoContext: avatarAndNameInfoContext, tapAvatarAction: { + let _ = (account.postbox.loadedPeerWithId(peerId) |> take(1) |> deliverOnMainQueue).start(next: { peer in + if peer.profileImageRepresentations.isEmpty { + return + } + + let galleryController = AvatarGalleryController(account: account, peer: peer, replaceRootController: { controller, ready in + + }) + hiddenAvatarRepresentationDisposable.set((galleryController.hiddenMedia |> deliverOnMainQueue).start(next: { entry in + avatarAndNameInfoContext.hiddenAvatarRepresentation = entry?.representations.first + updateHiddenAvatarImpl?() + })) + presentControllerImpl?(galleryController, AvatarGalleryControllerPresentationArguments(transitionArguments: { entry in + return avatarGalleryTransitionArguments?(entry) + })) + }) + }, changeProfilePhoto: { + let emptyController = LegacyEmptyController() + let navigationController = makeLegacyNavigationController(rootController: emptyController) + navigationController.setNavigationBarHidden(true, animated: false) + navigationController.navigationBar.transform = CGAffineTransform(translationX: -1000.0, y: 0.0) + + let legacyController = LegacyController(legacyController: navigationController, presentation: .custom) + + presentControllerImpl?(legacyController, nil) + + let mixin = TGMediaAvatarMenuMixin(parentController: emptyController, hasDeleteButton: false, personalPhoto: true)! + mixin.applicationInterface = legacyController.applicationInterface + let _ = currentAvatarMixin.swap(mixin) + mixin.didDismiss = { [weak legacyController] in + legacyController?.dismiss() + } + mixin.didFinishWithImage = { image in + if let image = image { + if let data = UIImageJPEGRepresentation(image, 0.6) { + let resource = LocalFileMediaResource(fileId: arc4random64()) + account.postbox.mediaBox.storeResourceData(resource.id, data: data) + let representation = TelegramMediaImageRepresentation(dimensions: CGSize(width: 640.0, height: 640.0), resource: resource) + updateState { + $0.withUpdatedUpdatingAvatar(representation) + } + updateAvatarDisposable.set((updatePeerPhoto(account: account, peerId: peerId, resource: resource) |> deliverOnMainQueue).start(next: { result in + switch result { + case .complete: + updateState { + $0.withUpdatedUpdatingAvatar(nil) + } + case .progress: + break + } + })) + } + } + } + mixin.didDismiss = { [weak legacyController] in + let _ = currentAvatarMixin.swap(nil) + legacyController?.dismiss() + } + mixin.present() + }, pushController: { controller in pushControllerImpl?(controller) }, presentController: { controller, presentationArguments in presentControllerImpl?(controller, presentationArguments) @@ -1180,7 +1272,7 @@ public func groupInfoController(account: Account, peerId: PeerId) -> ViewControl }) } - let controllerState = ItemListControllerState(title: "Info", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton) + let controllerState = ItemListControllerState(title: .text("Info"), leftNavigationButton: nil, rightNavigationButton: rightNavigationButton) let listState = ItemListNodeState(entries: groupInfoEntries(account: account, view: view, state: state), style: .blocks) return (controllerState, (listState, arguments)) @@ -1196,5 +1288,28 @@ public func groupInfoController(account: Account, peerId: PeerId) -> ViewControl presentControllerImpl = { [weak controller] value, presentationArguments in controller?.present(value, in: .window, with: presentationArguments) } + avatarGalleryTransitionArguments = { [weak controller] entry in + if let controller = controller { + var result: (ASDisplayNode, CGRect)? + controller.forEachItemNode { itemNode in + if let itemNode = itemNode as? ItemListAvatarAndNameInfoItemNode { + result = itemNode.avatarTransitionNode() + } + } + if let (node, _) = result { + return GalleryTransitionArguments(transitionNode: node, transitionContainerNode: controller.displayNode, transitionBackgroundNode: controller.displayNode) + } + } + return nil + } + updateHiddenAvatarImpl = { [weak controller] in + if let controller = controller { + controller.forEachItemNode { itemNode in + if let itemNode = itemNode as? ItemListAvatarAndNameInfoItemNode { + itemNode.updateAvatarHidden() + } + } + } + } return controller } diff --git a/TelegramUI/GroupsInCommonController.swift b/TelegramUI/GroupsInCommonController.swift index 4fe3a5bbad..bc8edc1d8c 100644 --- a/TelegramUI/GroupsInCommonController.swift +++ b/TelegramUI/GroupsInCommonController.swift @@ -88,7 +88,7 @@ private enum GroupsInCommonEntry: ItemListNodeEntry { func item(_ arguments: GroupsInCommonControllerArguments) -> ListViewItem { switch self { case let .peerItem(_, peer): - return ItemListPeerItem(account: arguments.account, peer: peer, presence: nil, text: .none, label: nil, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: false), switchValue: nil, enabled: true, sectionId: self.section, action: { + return ItemListPeerItem(account: arguments.account, peer: peer, presence: nil, text: .none, label: .none, editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: false), switchValue: nil, enabled: true, sectionId: self.section, action: { arguments.openPeer(peer.id) }, setPeerIdWithRevealedOptions: { _ in }, removePeer: { _ in @@ -162,7 +162,7 @@ public func groupsInCommonController(account: Account, peerId: PeerId) -> ViewCo let previous = previousPeers previousPeers = peers - let controllerState = ItemListControllerState(title: "Groups in Common", leftNavigationButton: nil, rightNavigationButton: nil, animateChanges: false) + let controllerState = ItemListControllerState(title: .text("Groups in Common"), leftNavigationButton: nil, rightNavigationButton: nil, animateChanges: false) let listState = ItemListNodeState(entries: groupsInCommonControllerEntries(state: state, peers: peers), style: .blocks, emptyStateItem: emptyStateItem, animateChanges: previous != nil && peers != nil && previous!.count >= peers!.count) return (controllerState, (listState, arguments)) diff --git a/TelegramUI/HapticFeedback.swift b/TelegramUI/HapticFeedback.swift index 2ed31829f1..82254348de 100644 --- a/TelegramUI/HapticFeedback.swift +++ b/TelegramUI/HapticFeedback.swift @@ -4,14 +4,15 @@ import UIKit @available(iOSApplicationExtension 10.0, *) private final class HapticFeedbackImpl { private lazy var impactGenerator = { UIImpactFeedbackGenerator(style: .light) }() + private lazy var selectionGenerator = { UISelectionFeedbackGenerator() }() private lazy var notificationGenerator = { UINotificationFeedbackGenerator() }() func prepareTap() { - self.impactGenerator.prepare() + self.selectionGenerator.prepare() } func tap() { - self.impactGenerator.impactOccurred() + self.selectionGenerator.selectionChanged() } func success() { diff --git a/TelegramUI/HorizontalStickersChatContextPanelNode.swift b/TelegramUI/HorizontalStickersChatContextPanelNode.swift index 721e4d1892..d9f5d80514 100644 --- a/TelegramUI/HorizontalStickersChatContextPanelNode.swift +++ b/TelegramUI/HorizontalStickersChatContextPanelNode.swift @@ -183,7 +183,7 @@ final class HorizontalStickersChatContextPanelNode: ChatInputContextPanelNode { self.gridNode.bounds = CGRect(x: gridBounds.minX, y: gridBounds.minY, width: gridFrame.size.height, height: gridFrame.size.width) self.gridNode.position = CGPoint(x: gridFrame.size.width / 2.0, y: gridFrame.size.height / 2.0) - self.gridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: CGSize(width: gridFrame.size.height, height: gridFrame.size.width), insets: UIEdgeInsets(top: 3.0, left: 0.0, bottom: 3.0, right: 0.0), preloadSize: 100.0, itemSize: CGSize(width: 66.0, height: 66.0)), transition: .immediate), stationaryItems: .all, updateFirstIndexInSectionOffset: nil), completion: { _ in }) + self.gridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: CGSize(width: gridFrame.size.height, height: gridFrame.size.width), insets: UIEdgeInsets(top: 3.0, left: 0.0, bottom: 3.0, right: 0.0), preloadSize: 100.0, type: .fixed(itemSize: CGSize(width: 66.0, height: 66.0))), transition: .immediate), stationaryItems: .all, updateFirstIndexInSectionOffset: nil), completion: { _ in }) let dequeue = self.validLayout == nil self.validLayout = (size, interfaceState) diff --git a/TelegramUI/ImageNode.swift b/TelegramUI/ImageNode.swift index 2badb16777..b9474f201b 100644 --- a/TelegramUI/ImageNode.swift +++ b/TelegramUI/ImageNode.swift @@ -18,6 +18,24 @@ public enum ImageCorner: Equatable { return CGSize() } } + + public var withoutTail: ImageCorner { + switch self { + case .Corner: + return self + case let .Tail(radius): + return .Corner(radius) + } + } + + public var radius: CGFloat { + switch self { + case let .Corner(radius): + return radius + case let .Tail(radius): + return radius + } + } } public func ==(lhs: ImageCorner, rhs: ImageCorner) -> Bool { @@ -69,6 +87,10 @@ public struct ImageCorners: Equatable { return UIEdgeInsets(top: 0.0, left: left, bottom: 0.0, right: right) } + + public func withRemovedTails() -> ImageCorners { + return ImageCorners(topLeft: self.topLeft.withoutTail, topRight: self.topRight.withoutTail, bottomLeft: self.bottomLeft.withoutTail, bottomRight: self.bottomRight.withoutTail) + } } public func ==(lhs: ImageCorners, rhs: ImageCorners) -> Bool { @@ -78,6 +100,8 @@ public func ==(lhs: ImageCorners, rhs: ImageCorners) -> Bool { public class ImageNode: ASDisplayNode { private var disposable = MetaDisposable() private let hasImage: ValuePromise? + private var first = true + private let enableEmpty: Bool var ready: Signal { if let hasImage = self.hasImage { @@ -87,24 +111,28 @@ public class ImageNode: ASDisplayNode { } } - init(enableHasImage: Bool = false) { + init(enableHasImage: Bool = false, enableEmpty: Bool = false) { if enableHasImage { self.hasImage = ValuePromise(false, ignoreRepeated: true) } else { self.hasImage = nil } + self.enableEmpty = enableEmpty super.init() } public func setSignal(_ signal: Signal) { - var first = true var reportedHasImage = false self.disposable.set((signal |> deliverOnMainQueue).start(next: {[weak self] next in dispatcher.dispatch { if let strongSelf = self { - strongSelf.contents = next?.cgImage - if first && next != nil { - first = false + if let image = next?.cgImage { + strongSelf.contents = image + } else if strongSelf.enableEmpty { + strongSelf.contents = nil + } + if strongSelf.first && next != nil { + strongSelf.first = false if strongSelf.isNodeLoaded { strongSelf.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.18) } diff --git a/TelegramUI/InstalledStickerPacksController.swift b/TelegramUI/InstalledStickerPacksController.swift index 0c9d5a2a38..4ebab76530 100644 --- a/TelegramUI/InstalledStickerPacksController.swift +++ b/TelegramUI/InstalledStickerPacksController.swift @@ -413,7 +413,7 @@ public func installedStickerPacksController(account: Account, mode: InstalledSti let previous = previousPackCount previousPackCount = packCount - let controllerState = ItemListControllerState(title: mode == .general ? "Stickers" : "Masks", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) + let controllerState = ItemListControllerState(title: .text(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)) diff --git a/TelegramUI/ItemListAvatarAndNameItem.swift b/TelegramUI/ItemListAvatarAndNameItem.swift index c8e30402d0..2264c47b13 100644 --- a/TelegramUI/ItemListAvatarAndNameItem.swift +++ b/TelegramUI/ItemListAvatarAndNameItem.swift @@ -5,6 +5,8 @@ import Postbox import TelegramCore import SwiftSignalKit +private let updatingAvatarOverlayImage = generateFilledCircleImage(diameter: 66.0, color: UIColor(white: 1.0, alpha: 0.5), backgroundColor: nil) + enum ItemListAvatarAndNameInfoItemName: Equatable { case personName(firstName: String, lastName: String) case title(title: String) @@ -35,7 +37,7 @@ enum ItemListAvatarAndNameInfoItemName: Equatable { var isEmpty: Bool { switch self { - case let .personName(firstName, lastName): + case let .personName(firstName, _): return firstName.isEmpty case let .title(title): return title.isEmpty @@ -75,6 +77,10 @@ struct ItemListAvatarAndNameInfoItemState: Equatable { } } +final class ItemListAvatarAndNameInfoItemContext { + var hiddenAvatarRepresentation: TelegramMediaImageRepresentation? +} + class ItemListAvatarAndNameInfoItem: ListViewItem, ItemListItem { let account: Account let peer: Peer? @@ -84,8 +90,11 @@ class ItemListAvatarAndNameInfoItem: ListViewItem, ItemListItem { let sectionId: ItemListSectionId let style: ItemListStyle let editingNameUpdated: (ItemListAvatarAndNameInfoItemName) -> Void + let avatarTapped: () -> Void + let context: ItemListAvatarAndNameInfoItemContext? + let updatingImage: TelegramMediaImageRepresentation? - init(account: Account, peer: Peer?, presence: PeerPresence?, cachedData: CachedPeerData?, state: ItemListAvatarAndNameInfoItemState, sectionId: ItemListSectionId, style: ItemListStyle, editingNameUpdated: @escaping (ItemListAvatarAndNameInfoItemName) -> Void) { + init(account: Account, peer: Peer?, presence: PeerPresence?, cachedData: CachedPeerData?, state: ItemListAvatarAndNameInfoItemState, sectionId: ItemListSectionId, style: ItemListStyle, editingNameUpdated: @escaping (ItemListAvatarAndNameInfoItemName) -> Void, avatarTapped: @escaping () -> Void, context: ItemListAvatarAndNameInfoItemContext? = nil, updatingImage: TelegramMediaImageRepresentation? = nil) { self.account = account self.peer = peer self.presence = presence @@ -94,6 +103,9 @@ class ItemListAvatarAndNameInfoItem: ListViewItem, ItemListItem { self.sectionId = sectionId self.style = style self.editingNameUpdated = editingNameUpdated + self.avatarTapped = avatarTapped + self.context = context + self.updatingImage = updatingImage } func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, () -> Void)) -> Void) { @@ -141,6 +153,8 @@ class ItemListAvatarAndNameInfoItemNode: ListViewItemNode { private let bottomStripeNode: ASDisplayNode private let avatarNode: AvatarNode + private let updatingAvatarOverlay: ASImageNode + private let nameNode: TextNode private let statusNode: TextNode @@ -152,6 +166,8 @@ class ItemListAvatarAndNameInfoItemNode: ListViewItemNode { private var layoutWidthAndNeighbors: (width: CGFloat, neighbors: ItemListNeighbors)? private var peerPresenceManager: PeerPresenceStatusManager? + private let hiddenAvatarRepresentationDisposable = MetaDisposable() + init() { self.backgroundNode = ASDisplayNode() self.backgroundNode.isLayerBacked = true @@ -167,6 +183,10 @@ class ItemListAvatarAndNameInfoItemNode: ListViewItemNode { self.avatarNode = AvatarNode(font: Font.regular(28.0)) + self.updatingAvatarOverlay = ASImageNode() + self.updatingAvatarOverlay.displayWithoutProcessing = true + self.updatingAvatarOverlay.displaysAsynchronously = false + self.nameNode = TextNode() self.nameNode.isLayerBacked = true self.nameNode.contentMode = .left @@ -191,9 +211,20 @@ class ItemListAvatarAndNameInfoItemNode: ListViewItemNode { }) } + deinit { + self.hiddenAvatarRepresentationDisposable.dispose() + } + + override func didLoad() { + super.didLoad() + + self.avatarNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.avatarTapGesture(_:)))) + } + func asyncLayout() -> (_ item: ItemListAvatarAndNameInfoItem, _ width: CGFloat, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, (Bool) -> Void) { let layoutNameNode = TextNode.asyncLayout(self.nameNode) let layoutStatusNode = TextNode.asyncLayout(self.statusNode) + let currentOverlayImage = self.updatingAvatarOverlay.image return { item, width, neighbors in let displayTitle: ItemListAvatarAndNameInfoItemName @@ -262,14 +293,41 @@ class ItemListAvatarAndNameInfoItemNode: ListViewItemNode { insets = UIEdgeInsets(top: topInset, left: 0.0, bottom: separatorHeight, right: 0.0) } + var updateAvatarOverlayImage: UIImage? + if item.updatingImage != nil && currentOverlayImage == nil { + updateAvatarOverlayImage = updatingAvatarOverlayImage + } + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) let layoutSize = layout.size return (layout, { [weak self] animated in if let strongSelf = self { strongSelf.item = item + strongSelf.layoutWidthAndNeighbors = (width, neighbors) + if item.updatingImage != nil { + if let updateAvatarOverlayImage = updateAvatarOverlayImage { + strongSelf.updatingAvatarOverlay.image = updateAvatarOverlayImage + } + strongSelf.updatingAvatarOverlay.alpha = 1.0 + if strongSelf.updatingAvatarOverlay.supernode == nil { + strongSelf.insertSubnode(strongSelf.updatingAvatarOverlay, aboveSubnode: strongSelf.avatarNode) + } + } else if strongSelf.updatingAvatarOverlay.supernode != nil { + if animated { + strongSelf.updatingAvatarOverlay.alpha = 0.0 + strongSelf.updatingAvatarOverlay.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, completion: { value in + if value { + self?.updatingAvatarOverlay.removeFromSupernode() + } + }) + } else { + strongSelf.updatingAvatarOverlay.removeFromSupernode() + } + } + let avatarOriginY: CGFloat switch item.style { case .plain: @@ -336,10 +394,13 @@ class ItemListAvatarAndNameInfoItemNode: ListViewItemNode { }*/ if let peer = item.peer { - strongSelf.avatarNode.setPeer(account: item.account, peer: peer) + strongSelf.avatarNode.setPeer(account: item.account, peer: peer, temporaryRepresentation: item.updatingImage) } - strongSelf.avatarNode.frame = CGRect(origin: CGPoint(x: 15.0, y: avatarOriginY), size: CGSize(width: 66.0, height: 66.0)) + let avatarFrame = CGRect(origin: CGPoint(x: 15.0, y: avatarOriginY), size: CGSize(width: 66.0, height: 66.0)) + strongSelf.avatarNode.frame = avatarFrame + strongSelf.updatingAvatarOverlay.frame = avatarFrame + strongSelf.nameNode.frame = CGRect(origin: CGPoint(x: 94.0, y: 25.0), size: nameNodeLayout.size) strongSelf.statusNode.frame = CGRect(origin: CGPoint(x: 94.0, y: 25.0 + nameNodeLayout.size.height + 4.0), size: statusNodeLayout.size) @@ -483,6 +544,8 @@ class ItemListAvatarAndNameInfoItemNode: ListViewItemNode { if let presence = item.presence as? TelegramUserPresence { strongSelf.peerPresenceManager?.reset(presence: presence) } + + strongSelf.updateAvatarHidden() } }) } @@ -501,4 +564,26 @@ class ItemListAvatarAndNameInfoItemNode: ListViewItemNode { } } } + + @objc func avatarTapGesture(_ recognizer: UITapGestureRecognizer) { + if let item = self.item { + item.avatarTapped() + } + } + + func avatarTransitionNode() -> (ASDisplayNode, CGRect) { + return (self.avatarNode, self.avatarNode.bounds) + } + + func updateAvatarHidden() { + var hidden = false + if let item = self.item, let context = item.context, let peer = item.peer, let hiddenAvatarRepresentation = context.hiddenAvatarRepresentation { + if peer.profileImageRepresentations.contains(hiddenAvatarRepresentation) { + hidden = true + } + } + if hidden != self.avatarNode.isHidden { + self.avatarNode.isHidden = hidden + } + } } diff --git a/TelegramUI/ItemListController.swift b/TelegramUI/ItemListController.swift index ba05eae955..6d9432dc33 100644 --- a/TelegramUI/ItemListController.swift +++ b/TelegramUI/ItemListController.swift @@ -24,13 +24,35 @@ struct ItemListNavigationButton { let action: () -> Void } +enum ItemListControllerTitle: Equatable { + case text(String) + case sectionControl([String], Int) + + static func ==(lhs: ItemListControllerTitle, rhs: ItemListControllerTitle) -> Bool { + switch lhs { + case let .text(text): + if case .text(text) = rhs { + return true + } else { + return false + } + case let .sectionControl(lhsSection, lhsIndex): + if case let .sectionControl(rhsSection, rhsIndex) = rhs { + return true + } else { + return false + } + } + } +} + struct ItemListControllerState { - let title: String + let title: ItemListControllerTitle let leftNavigationButton: ItemListNavigationButton? let rightNavigationButton: ItemListNavigationButton? let animateChanges: Bool - init(title: String, leftNavigationButton: ItemListNavigationButton?, rightNavigationButton: ItemListNavigationButton?, animateChanges: Bool = true) { + init(title: ItemListControllerTitle, leftNavigationButton: ItemListNavigationButton?, rightNavigationButton: ItemListNavigationButton?, animateChanges: Bool = true) { self.title = title self.leftNavigationButton = leftNavigationButton self.rightNavigationButton = rightNavigationButton @@ -44,9 +66,12 @@ final class ItemListController: ViewController { private var leftNavigationButtonTitleAndStyle: (String, ItemListNavigationButtonStyle)? private var rightNavigationButtonTitleAndStyle: (String, ItemListNavigationButtonStyle)? private var navigationButtonActions: (left: (() -> Void)?, right: (() -> Void)?) = (nil, nil) + private var segmentedTitleView: ItemListControllerSegmentedTitleView? private var didPlayPresentationAnimation = false + var titleControlValueChanged: ((Int) -> Void)? + private let _ready = Promise() override var ready: Promise { return self._ready @@ -79,7 +104,26 @@ final class ItemListController: ViewController { if let strongSelf = self { let previousState = previousControllerState.swap(controllerState) if previousState?.title != controllerState.title { - strongSelf.title = controllerState.title + switch controllerState.title { + case let .text(text): + strongSelf.title = text + strongSelf.navigationItem.titleView = nil + strongSelf.segmentedTitleView = nil + case let .sectionControl(sections, index): + strongSelf.title = "" + if let segmentedTitleView = strongSelf.segmentedTitleView, segmentedTitleView.segments == sections { + segmentedTitleView.index = index + } else { + let segmentedTitleView = ItemListControllerSegmentedTitleView(segments: sections, index: index) + strongSelf.segmentedTitleView = segmentedTitleView + strongSelf.navigationItem.titleView = strongSelf.segmentedTitleView + segmentedTitleView.indexUpdated = { index in + if let strongSelf = self { + strongSelf.titleControlValueChanged?(index) + } + } + } + } } strongSelf.navigationButtonActions = (left: controllerState.leftNavigationButton?.action, right: controllerState.rightNavigationButton?.action) @@ -154,8 +198,8 @@ final class ItemListController: ViewController { } } - override func dismiss() { - (self.displayNode as! ItemListNode).animateOut() + override func dismiss(completion: (() -> Void)? = nil) { + (self.displayNode as! ItemListNode).animateOut(completion: completion) } func frameForItemNode(_ predicate: (ListViewItemNode) -> Bool) -> CGRect? { @@ -169,4 +213,12 @@ final class ItemListController: ViewController { } return result } + + func forEachItemNode(_ f: (ListViewItemNode) -> Void) { + (self.displayNode as! ItemListNode).listNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ListViewItemNode { + f(itemNode) + } + } + } } diff --git a/TelegramUI/ItemListControllerNode.swift b/TelegramUI/ItemListControllerNode.swift index b4ae8ecaaa..8a45bf638b 100644 --- a/TelegramUI/ItemListControllerNode.swift +++ b/TelegramUI/ItemListControllerNode.swift @@ -158,11 +158,12 @@ final class ItemListNode: ASDisplayNode { 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() { + func animateOut(completion: (() -> Void)? = nil) { 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?() } + completion?() }) } diff --git a/TelegramUI/ItemListControllerSegmentedTitleView.swift b/TelegramUI/ItemListControllerSegmentedTitleView.swift new file mode 100644 index 0000000000..eb743924b3 --- /dev/null +++ b/TelegramUI/ItemListControllerSegmentedTitleView.swift @@ -0,0 +1,46 @@ +import Foundation +import UIKit + +final class ItemListControllerSegmentedTitleView: UIView { + let segments: [String] + var index: Int { + didSet { + self.control.selectedSegmentIndex = self.index + } + } + + private let control: UISegmentedControl + + var indexUpdated: ((Int) -> Void)? + + init(segments: [String], index: Int) { + self.segments = segments + self.index = index + + self.control = UISegmentedControl(items: segments) + self.control.selectedSegmentIndex = index + + super.init(frame: CGRect()) + + self.addSubview(self.control) + self.control.addTarget(self, action: #selector(indexChanged), for: .valueChanged) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + let size = self.bounds.size + + var controlSize = self.control.sizeThatFits(size) + controlSize.width = min(size.width, max(200.0, controlSize.width)) + self.control.frame = CGRect(origin: CGPoint(x: floor((size.width - controlSize.width) / 2.0), y: floor((size.height - controlSize.height) / 2.0)), size: controlSize) + } + + @objc func indexChanged() { + self.indexUpdated?(self.control.selectedSegmentIndex) + } +} diff --git a/TelegramUI/ItemListDisclosureItem.swift b/TelegramUI/ItemListDisclosureItem.swift index e4417d41bc..3db0538178 100644 --- a/TelegramUI/ItemListDisclosureItem.swift +++ b/TelegramUI/ItemListDisclosureItem.swift @@ -3,18 +3,25 @@ import Display import AsyncDisplayKit import SwiftSignalKit +enum ItemListDisclosureStyle { + case arrow + case none +} + class ItemListDisclosureItem: ListViewItem, ItemListItem { let title: String let label: String let sectionId: ItemListSectionId let style: ItemListStyle - let action: () -> Void + let disclosureStyle: ItemListDisclosureStyle + let action: (() -> Void)? - init(title: String, label: String, sectionId: ItemListSectionId, style: ItemListStyle, action: @escaping () -> Void) { + init(title: String, label: String, sectionId: ItemListSectionId, style: ItemListStyle, disclosureStyle: ItemListDisclosureStyle = .arrow, action: (() -> Void)?) { self.title = title self.label = label self.sectionId = sectionId self.style = style + self.disclosureStyle = disclosureStyle self.action = action } @@ -53,7 +60,7 @@ class ItemListDisclosureItem: ListViewItem, ItemListItem { func selected(listView: ListView){ listView.clearHighlightAnimated(true) - self.action() + self.action?() } } @@ -70,6 +77,16 @@ class ItemListDisclosureItemNode: ListViewItemNode { let labelNode: TextNode let arrowNode: ASImageNode + private var item: ItemListDisclosureItem? + + override var canBeSelected: Bool { + if let item = self.item, let _ = item.action { + return true + } else { + return false + } + } + init() { self.backgroundNode = ASDisplayNode() self.backgroundNode.isLayerBacked = true @@ -111,7 +128,14 @@ class ItemListDisclosureItemNode: ListViewItemNode { let makeLabelLayout = TextNode.asyncLayout(self.labelNode) return { item, width, neighbors in - let rightInset: CGFloat = 34.0 + var rightInset: CGFloat = 34.0 + + switch item.disclosureStyle { + case .none: + rightInset = 16.0 + case .arrow: + rightInset = 34.0 + } let contentSize: CGSize let insets: UIEdgeInsets @@ -134,6 +158,8 @@ class ItemListDisclosureItemNode: ListViewItemNode { return (ListViewItemNodeLayout(contentSize: contentSize, insets: insets), { [weak self] in if let strongSelf = self { + strongSelf.item = item + let _ = titleApply() let _ = labelApply() @@ -192,6 +218,13 @@ class ItemListDisclosureItemNode: ListViewItemNode { strongSelf.arrowNode.frame = CGRect(origin: CGPoint(x: width - 15.0 - arrowImage.size.width, y: 18.0), size: arrowImage.size) } + switch item.disclosureStyle { + case .none: + strongSelf.arrowNode.isHidden = true + case .arrow: + strongSelf.arrowNode.isHidden = false + } + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: width, height: 44.0 + UIScreenPixel + UIScreenPixel)) } }) diff --git a/TelegramUI/ItemListPeerItem.swift b/TelegramUI/ItemListPeerItem.swift index cd8e604bf2..ac0f1e5b70 100644 --- a/TelegramUI/ItemListPeerItem.swift +++ b/TelegramUI/ItemListPeerItem.swift @@ -30,12 +30,18 @@ enum ItemListPeerItemText { case none } +enum ItemListPeerItemLabel { + case none + case text(String) + case disclosure(String) +} + final class ItemListPeerItem: ListViewItem, ItemListItem { let account: Account let peer: Peer let presence: PeerPresence? let text: ItemListPeerItemText - let label: String? + let label: ItemListPeerItemLabel let editing: ItemListPeerItemEditing let switchValue: Bool? let enabled: Bool @@ -45,7 +51,7 @@ final class ItemListPeerItem: ListViewItem, ItemListItem { let removePeer: (PeerId) -> Void let toggleUpdated: ((Bool) -> Void)? - init(account: Account, peer: Peer, presence: PeerPresence?, text: ItemListPeerItemText, label: String?, editing: ItemListPeerItemEditing, switchValue: Bool?, enabled: Bool, sectionId: ItemListSectionId, action: (() -> Void)?, setPeerIdWithRevealedOptions: @escaping (PeerId?, PeerId?) -> Void, removePeer: @escaping (PeerId) -> Void, toggleUpdated: ((Bool) -> Void)? = nil) { + init(account: Account, peer: Peer, presence: PeerPresence?, text: ItemListPeerItemText, label: ItemListPeerItemLabel, editing: ItemListPeerItemEditing, switchValue: Bool?, enabled: Bool, sectionId: ItemListSectionId, action: (() -> Void)?, setPeerIdWithRevealedOptions: @escaping (PeerId?, PeerId?) -> Void, removePeer: @escaping (PeerId) -> Void, toggleUpdated: ((Bool) -> Void)? = nil) { self.account = account self.peer = peer self.presence = presence @@ -109,8 +115,11 @@ private let titleFont = Font.regular(17.0) private let titleBoldFont = Font.medium(17.0) private let statusFont = Font.regular(14.0) private let labelFont = Font.regular(13.0) +private let labelDisclosureFont = Font.regular(17.0) private let avatarFont = Font.regular(17.0) +private let arrowImage = UIImage(bundleImageName: "Peer Info/DisclosureArrow")?.precomposed() + class ItemListPeerItemNode: ItemListRevealOptionsItemNode { private let backgroundNode: ASDisplayNode private let topStripeNode: ASDisplayNode @@ -121,6 +130,7 @@ class ItemListPeerItemNode: ItemListRevealOptionsItemNode { fileprivate let avatarNode: AvatarNode private let titleNode: TextNode private let labelNode: TextNode + private var labelArrowNode: ASImageNode? private let statusNode: TextNode private var switchNode: SwitchNode? @@ -200,6 +210,8 @@ class ItemListPeerItemNode: ItemListRevealOptionsItemNode { var currentSwitchNode = self.switchNode + var currentLabelArrowNode = self.labelArrowNode + return { item, width, neighbors in var titleAttributedString: NSAttributedString? var statusAttributedString: NSAttributedString? @@ -258,11 +270,7 @@ class ItemListPeerItemNode: ItemListRevealOptionsItemNode { case .none: break } - - if let label = item.label { - labelAttributedString = NSAttributedString(string: label, font: labelFont, textColor: UIColor(0xa6a6a6)) - } - + let leftInset: CGFloat = 65.0 var editableControlSizeAndApply: (CGSize, () -> ItemListEditableControlNode)? @@ -276,10 +284,33 @@ class ItemListPeerItemNode: ItemListRevealOptionsItemNode { editingOffset = 0.0 } + var labelInset: CGFloat = 0.0 + var updatedLabelArrowNode: ASImageNode? + switch item.label { + case .none: + break + case let .text(text): + labelAttributedString = NSAttributedString(string: text, font: labelFont, textColor: UIColor(0xa6a6a6)) + rightInset += 10.0 + case let .disclosure(text): + if let currentLabelArrowNode = currentLabelArrowNode { + updatedLabelArrowNode = currentLabelArrowNode + } else { + let arrowNode = ASImageNode() + arrowNode.isLayerBacked = true + arrowNode.displayWithoutProcessing = true + arrowNode.displaysAsynchronously = false + arrowNode.image = arrowImage + updatedLabelArrowNode = arrowNode + } + labelInset += 30.0 + labelAttributedString = NSAttributedString(string: text, font: labelDisclosureFont, textColor: UIColor(0x8e8e93)) + } + let (labelLayout, labelApply) = makeLabelLayout(labelAttributedString, nil, 1, .end, CGSize(width: width - leftInset - 8.0 - editingOffset - rightInset, height: CGFloat.greatestFiniteMagnitude), .natural, nil, UIEdgeInsets()) - let (titleLayout, titleApply) = makeTitleLayout(titleAttributedString, nil, 1, .end, CGSize(width: width - leftInset - 8.0 - labelLayout.size.width - editingOffset - rightInset, height: CGFloat.greatestFiniteMagnitude), .natural, nil, UIEdgeInsets()) - let (statusLayout, statusApply) = makeStatusLayout(statusAttributedString, nil, 1, .end, CGSize(width: width - leftInset - 8.0 - (labelLayout.size.width > 0.0 ? (labelLayout.size.width) + 15.0 : 0.0) - editingOffset - rightInset, height: CGFloat.greatestFiniteMagnitude), .natural, nil, UIEdgeInsets()) + let (titleLayout, titleApply) = makeTitleLayout(titleAttributedString, nil, 1, .end, CGSize(width: width - leftInset - 8.0 - labelLayout.size.width - editingOffset - rightInset - labelInset, height: CGFloat.greatestFiniteMagnitude), .natural, nil, UIEdgeInsets()) + let (statusLayout, statusApply) = makeStatusLayout(statusAttributedString, nil, 1, .end, CGSize(width: width - leftInset - 8.0 - (labelLayout.size.width > 0.0 ? (labelLayout.size.width) + 15.0 : 0.0) - editingOffset - rightInset - labelInset, height: CGFloat.greatestFiniteMagnitude), .natural, nil, UIEdgeInsets()) let insets = itemListNeighborsGroupedInsets(neighbors) let contentSize = CGSize(width: width, height: 48.0) @@ -415,8 +446,23 @@ class ItemListPeerItemNode: ItemListRevealOptionsItemNode { switchNode.removeFromSupernode() strongSelf.switchNode = nil } + + var rightLabelInset: CGFloat = 15.0 + + if let updatedLabelArrowNode = updatedLabelArrowNode { + strongSelf.labelArrowNode = updatedLabelArrowNode + strongSelf.addSubnode(updatedLabelArrowNode) + if let image = updatedLabelArrowNode.image { + let labelArrowNodeFrame = CGRect(origin: CGPoint(x: width - rightLabelInset - image.size.width, y: floor((contentSize.height - image.size.height) / 2.0)), size: image.size) + transition.updateFrame(node: updatedLabelArrowNode, frame: labelArrowNodeFrame) + rightLabelInset += 19.0 + } + } else if let labelArrowNode = strongSelf.labelArrowNode { + labelArrowNode.removeFromSupernode() + strongSelf.labelArrowNode = nil + } - transition.updateFrame(node: strongSelf.labelNode, frame: CGRect(origin: CGPoint(x: revealOffset + width - labelLayout.size.width - 15.0 - rightInset, y: floor((contentSize.height - labelLayout.size.height) / 2.0 - labelLayout.size.height / 10.0)), size: labelLayout.size)) + transition.updateFrame(node: strongSelf.labelNode, frame: CGRect(origin: CGPoint(x: revealOffset + width - labelLayout.size.width - rightLabelInset - rightInset, y: floor((contentSize.height - labelLayout.size.height) / 2.0)), size: labelLayout.size)) transition.updateFrame(node: strongSelf.avatarNode, frame: CGRect(origin: CGPoint(x: revealOffset + editingOffset + 12.0, y: 4.0), size: CGSize(width: 40.0, height: 40.0))) strongSelf.avatarNode.setPeer(account: item.account, peer: item.peer) @@ -498,7 +544,18 @@ class ItemListPeerItemNode: ItemListRevealOptionsItemNode { 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: 25.0), size: self.statusNode.bounds.size)) - transition.updateFrame(node: self.labelNode, frame: CGRect(origin: CGPoint(x: revealOffset + width - self.labelNode.bounds.size.width - 15.0, y: floor((self.contentSize.height - self.labelNode.bounds.size.height) / 2.0 - self.labelNode.bounds.size.height / 10.0)), size: self.labelNode.bounds.size)) + + var rightLabelInset: CGFloat = 15.0 + + if let labelArrowNode = self.labelArrowNode { + if let image = labelArrowNode.image { + let labelArrowNodeFrame = CGRect(origin: CGPoint(x: revealOffset + width - rightLabelInset - image.size.width, y: labelArrowNode.frame.minY), size: image.size) + transition.updateFrame(node: labelArrowNode, frame: labelArrowNodeFrame) + rightLabelInset += 19.0 + } + } + + transition.updateFrame(node: self.labelNode, frame: CGRect(origin: CGPoint(x: revealOffset + width - self.labelNode.bounds.size.width - rightLabelInset, y: floor((self.contentSize.height - self.labelNode.bounds.size.height) / 2.0 - self.labelNode.bounds.size.height / 10.0)), size: self.labelNode.bounds.size)) transition.updateFrame(node: avatarNode, frame: CGRect(origin: CGPoint(x: revealOffset + editingOffset + 12.0, y: 4.0), size: CGSize(width: 40.0, height: 40.0))) } diff --git a/TelegramUI/ItemListStickerPackItem.swift b/TelegramUI/ItemListStickerPackItem.swift index 508f5496eb..ca08ba90a3 100644 --- a/TelegramUI/ItemListStickerPackItem.swift +++ b/TelegramUI/ItemListStickerPackItem.swift @@ -331,11 +331,11 @@ class ItemListStickerPackItemNode: ItemListRevealOptionsItemNode { } var updatedImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? - var updatedFetchSignal: Signal? + 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) + updatedFetchSignal = item.account.postbox.mediaBox.fetchedResource(file.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .generic)) } else { updatedImageSignal = .single({ _ in return nil }) updatedFetchSignal = .complete() diff --git a/TelegramUI/ItemListTextItem.swift b/TelegramUI/ItemListTextItem.swift index 6f5e14b34d..5b5db037bf 100644 --- a/TelegramUI/ItemListTextItem.swift +++ b/TelegramUI/ItemListTextItem.swift @@ -61,6 +61,7 @@ class ItemListTextItem: ListViewItem, ItemListItem { } private let titleFont = Font.regular(14.0) +private let titleBoldFont = Font.semibold(14.0) class ItemListTextItemNode: ListViewItemNode { private let titleNode: TextNode @@ -100,7 +101,7 @@ class ItemListTextItemNode: ListViewItemNode { 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 + attributedText = parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: titleFont, textColor: UIColor(0x6d6d72)), bold: MarkdownAttributeSet(font: titleBoldFont, textColor: UIColor(0x6d6d72)), link: MarkdownAttributeSet(font: titleFont, textColor: UIColor(0x007ee5)), linkAttribute: { contents in return (TextNode.UrlAttribute, contents) })) } diff --git a/TelegramUI/LegacyController.swift b/TelegramUI/LegacyController.swift index e881ee6683..c740636fec 100644 --- a/TelegramUI/LegacyController.swift +++ b/TelegramUI/LegacyController.swift @@ -2,17 +2,21 @@ import Foundation import Display import TelegramLegacyComponents -enum LegacyControllerPresentation { +public enum LegacyControllerPresentation { case custom - case modal + case modal(animateIn: Bool) } -private func passControllerAppearanceAnimated(presentation: LegacyControllerPresentation) -> Bool { +private func passControllerAppearanceAnimated(in: Bool, presentation: LegacyControllerPresentation) -> Bool { switch presentation { - case .custom: + case let .modal(animateIn): + if `in` { + return animateIn + } else { + return true + } + default: return false - case .modal: - return true } } @@ -80,9 +84,15 @@ private final class LegacyControllerApplicationInterface: NSObject, TGLegacyAppl public func animateApplicationStatusBarStyleTransition(withDuration duration: TimeInterval) { } + + public func makeOverlayControllerWindow(_ parentController: TGViewController!, contentController: TGOverlayController!, keepKeyboard: Bool) -> TGOverlayControllerWindow! { + return LegacyOverlayWindowHost(presentInWindow: { [weak self] c in + self?.controller?.present(c, in: .window) + }, parentController: parentController, contentController: contentController, keepKeyboard: keepKeyboard) + } } -class LegacyController: ViewController { +public class LegacyController: ViewController { private let legacyController: UIViewController private let presentation: LegacyControllerPresentation @@ -95,8 +105,9 @@ class LegacyController: ViewController { } var controllerLoaded: (() -> Void)? + public var presentationCompleted: (() -> Void)? - init(legacyController: UIViewController, presentation: LegacyControllerPresentation) { + public init(legacyController: UIViewController, presentation: LegacyControllerPresentation) { self.legacyController = legacyController self.presentation = presentation @@ -109,60 +120,65 @@ class LegacyController: ViewController { self.navigationBar.isHidden = true } - required init(coder aDecoder: NSCoder) { + required public init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - override func loadDisplayNode() { + override public func loadDisplayNode() { self.displayNode = LegacyControllerNode() + self.displayNodeDidLoad() } - override func viewWillAppear(_ animated: Bool) { + override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - if !self.legacyController.isViewLoaded { + if self.controllerNode.controllerView == nil { self.controllerNode.controllerView = self.legacyController.view - self.controllerNode.view.addSubview(self.legacyController.view) + self.controllerNode.view.insertSubview(self.legacyController.view, at: 0) if let controllerLoaded = self.controllerLoaded { controllerLoaded() } } - self.legacyController.viewWillAppear(animated && passControllerAppearanceAnimated(presentation: self.presentation)) + self.legacyController.viewWillAppear(animated && passControllerAppearanceAnimated(in: true, presentation: self.presentation)) } - override func viewWillDisappear(_ animated: Bool) { + override public func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - self.legacyController.viewWillDisappear(animated && passControllerAppearanceAnimated(presentation: self.presentation)) + self.legacyController.viewWillDisappear(animated && passControllerAppearanceAnimated(in: false, presentation: self.presentation)) } - override func viewDidAppear(_ animated: Bool) { + override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) switch self.presentation { - case .modal: - self.controllerNode.animateModalIn() - self.legacyController.viewDidAppear(true) + case let .modal(animateIn): + if animateIn { + self.controllerNode.animateModalIn(completion: { [weak self] in + self?.presentationCompleted?() + }) + } + self.legacyController.viewDidAppear(animated && animateIn) case .custom: self.legacyController.viewDidAppear(animated) } } - override func viewDidDisappear(_ animated: Bool) { + override public func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - self.legacyController.viewDidDisappear(animated && passControllerAppearanceAnimated(presentation: self.presentation)) + self.legacyController.viewDidDisappear(animated && passControllerAppearanceAnimated(in: false, presentation: self.presentation)) } - override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationHeight, transition: transition) } - override open func dismiss() { + override open func dismiss(completion: (() -> Void)? = nil) { switch self.presentation { case .modal: self.controllerNode.animateModalOut { [weak self] in @@ -171,7 +187,7 @@ class LegacyController: ViewController { } else if let controller = self?.legacyController as? TGNavigationController { controller.didDismiss() } - self?.presentingViewController?.dismiss(animated: false, completion: nil) + self?.presentingViewController?.dismiss(animated: false, completion: completion) } case .custom: if let controller = self.legacyController as? TGViewController { @@ -179,7 +195,7 @@ class LegacyController: ViewController { } else if let controller = self.legacyController as? TGNavigationController { controller.didDismiss() } - self.presentingViewController?.dismiss(animated: false, completion: nil) + self.presentingViewController?.dismiss(animated: false, completion: completion) } } } diff --git a/TelegramUI/LegacyControllerNode.swift b/TelegramUI/LegacyControllerNode.swift index 413680ddba..c9281adfdc 100644 --- a/TelegramUI/LegacyControllerNode.swift +++ b/TelegramUI/LegacyControllerNode.swift @@ -26,8 +26,10 @@ final class LegacyControllerNode: ASDisplayNode { } } - func animateModalIn() { - 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 animateModalIn(completion: @escaping () -> Void) { + self.layer.animatePosition(from: CGPoint(x: 0.0, y: self.layer.bounds.size.height), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true, completion: { _ in + completion() + }) } func animateModalOut(completion: @escaping () -> Void) { diff --git a/TelegramUI/LegacyEmptyController.swift b/TelegramUI/LegacyEmptyController.swift index c67a65a8ca..ad2f2da17c 100644 --- a/TelegramUI/LegacyEmptyController.swift +++ b/TelegramUI/LegacyEmptyController.swift @@ -1,5 +1,6 @@ import Foundation import TelegramLegacyComponents +import Display final class LegacyEmptyController: TGViewController { override func viewDidLoad() { @@ -7,3 +8,47 @@ final class LegacyEmptyController: TGViewController { self.view.isOpaque = false } } + +final class LegacyOverlayWindowHost: TGOverlayControllerWindow { + private let presentInWindow: (ViewController) -> Void + private let content: LegacyController + + init(presentInWindow: @escaping (ViewController) -> Void, parentController: TGViewController!, contentController: TGOverlayController!, keepKeyboard: Bool) { + self.content = LegacyController(legacyController: contentController, presentation: .custom) + self.presentInWindow = presentInWindow + + super.init(parentController: parentController, contentController: contentController, keepKeyboard: keepKeyboard) + + self.rootViewController = nil + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + if !self._isHidden { + self.content.dismiss() + } + } + + private var _isHidden = true + override var isHidden: Bool { + get { + return self._isHidden + } set(value) { + if value != self._isHidden { + self._isHidden = value + if !value { + self.presentInWindow(self.content) + } else { + self.content.dismiss() + } + } + } + } + + override func dismiss() { + self.isHidden = true + } +} diff --git a/TelegramUI/LegacyMediaPickers.swift b/TelegramUI/LegacyMediaPickers.swift index d7f62af041..9997b72690 100644 --- a/TelegramUI/LegacyMediaPickers.swift +++ b/TelegramUI/LegacyMediaPickers.swift @@ -230,7 +230,7 @@ func legacyAssetPickerEnqueueMessages(account: Account, peerId: PeerId, signals: resource = LocalFileVideoMediaResource(randomId: arc4random64(), path: path, adjustments: resourceAdjustments) } - let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: arc4random64()), resource: resource, previewRepresentations: previewRepresentations, mimeType: "video/mp4", size: nil, attributes: [.FileName(fileName: "video.mp4"), .Video(duration: Int(finalDuration), size: finalDimensions)]) + let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: arc4random64()), resource: resource, previewRepresentations: previewRepresentations, mimeType: "video/mp4", size: nil, attributes: [.FileName(fileName: "video.mp4"), .Video(duration: Int(finalDuration), size: finalDimensions, flags: [])]) messages.append(.message(text: caption ?? "", attributes: [], media: media, replyToMessageId: nil)) } } diff --git a/TelegramUI/ManagedAudioPlaylistPlayer.swift b/TelegramUI/ManagedAudioPlaylistPlayer.swift index d87d1e162a..29c81bf230 100644 --- a/TelegramUI/ManagedAudioPlaylistPlayer.swift +++ b/TelegramUI/ManagedAudioPlaylistPlayer.swift @@ -197,7 +197,7 @@ final class ManagedAudioPlaylistPlayer { } if let resource = item.resource { - let player = MediaPlayer(audioSessionManager: strongSelf.audioSessionManager, postbox: strongSelf.postbox, resource: resource, streamable: item.streamable) + let player = MediaPlayer(audioSessionManager: strongSelf.audioSessionManager, postbox: strongSelf.postbox, resource: resource, streamable: item.streamable, video: false, preferSoftwareDecoding: false, enableSound: true) player.actionAtEnd = .action({ if let strongSelf = self { strongSelf.control(.navigation(.next)) diff --git a/TelegramUI/ManagedVideoNode.swift b/TelegramUI/ManagedVideoNode.swift index 76401d1a33..b38573e30e 100644 --- a/TelegramUI/ManagedVideoNode.swift +++ b/TelegramUI/ManagedVideoNode.swift @@ -13,6 +13,21 @@ class ManagedVideoNode: ASDisplayNode { } } + private let _player = Promise(nil) + var player: Signal { + return self._player.get() + } + + let preferSoftwareDecoding: Bool + let backgroundThread: Bool + + init(preferSoftwareDecoding: Bool = false, backgroundThread: Bool = true) { + self.preferSoftwareDecoding = preferSoftwareDecoding + self.backgroundThread = backgroundThread + + super.init() + } + deinit { self.videoContextDisposable.dispose() } @@ -22,7 +37,7 @@ class ManagedVideoNode: ASDisplayNode { } func acquireContext(account: Account, mediaManager: MediaManager, id: ManagedMediaId, resource: MediaResource) { - self.videoContextDisposable.set((mediaManager.videoContext(account: account, id: id, resource: resource) |> deliverOnMainQueue).start(next: { [weak self] videoContext in + self.videoContextDisposable.set((mediaManager.videoContext(account: account, id: id, resource: resource, preferSoftwareDecoding: self.preferSoftwareDecoding, backgroundThread: self.backgroundThread) |> deliverOnMainQueue).start(next: { [weak self] videoContext in if let strongSelf = self { if strongSelf.videoContext !== videoContext { if let videoContext = strongSelf.videoContext { @@ -32,11 +47,12 @@ class ManagedVideoNode: ASDisplayNode { } strongSelf.videoContext = videoContext + strongSelf._player.set(.single(videoContext?.mediaPlayer)) if let videoContext = videoContext { strongSelf.addSubnode(videoContext.playerNode) videoContext.playerNode.transformArguments = strongSelf.transformArguments strongSelf.setNeedsLayout() - videoContext.mediaPlayer.play() + //videoContext.mediaPlayer.play() } } } diff --git a/TelegramUI/Markdown.swift b/TelegramUI/Markdown.swift index 52e16b0512..8077183958 100644 --- a/TelegramUI/Markdown.swift +++ b/TelegramUI/Markdown.swift @@ -1,7 +1,7 @@ import Foundation import Display -private let controlStartCharactersSet = CharacterSet(charactersIn: "[") +private let controlStartCharactersSet = CharacterSet(charactersIn: "[*") private let controlCharactersSet = CharacterSet(charactersIn: "[]()*_-\\") final class MarkdownAttributeSet { @@ -16,12 +16,14 @@ final class MarkdownAttributeSet { final class MarkdownAttributes { let body: MarkdownAttributeSet + let bold: MarkdownAttributeSet let link: MarkdownAttributeSet let linkAttribute: (String) -> (String, Any)? - init(body: MarkdownAttributeSet, link: MarkdownAttributeSet, linkAttribute: @escaping (String) -> (String, Any)?) { + init(body: MarkdownAttributeSet, bold: MarkdownAttributeSet, link: MarkdownAttributeSet, linkAttribute: @escaping (String) -> (String, Any)?) { self.body = body self.link = link + self.bold = bold self.linkAttribute = linkAttribute } } @@ -69,6 +71,27 @@ func parseMarkdownIntoAttributedString(_ string: String, attributes: MarkdownAtt } result.append(NSAttributedString(string: parsedLinkText, attributes: linkAttributes)) } + } else if character == UInt16(("*" as UnicodeScalar).value) { + if range.location + 1 != remainingRange.length { + let nextCharacter = nsString.character(at: range.location + 1) + if nextCharacter == character { + remainingRange = NSMakeRange(range.location + range.length + 1, remainingRange.location + remainingRange.length - (range.location + range.length + 1)) + + if let bold = parseBold(string: nsString, remainingRange: &remainingRange) { + let boldAttributes: [String: Any] = [NSFontAttributeName: attributes.bold.font, NSForegroundColorAttributeName: attributes.bold.textColor] + result.append(NSAttributedString(string: bold, attributes: boldAttributes)) + } else { + result.append(NSAttributedString(string: nsString.substring(with: NSMakeRange(remainingRange.location, 1)), attributes: bodyAttributes)) + remainingRange = NSMakeRange(range.location + 1, remainingRange.length - 1) + } + } else { + result.append(NSAttributedString(string: nsString.substring(with: NSMakeRange(remainingRange.location, 1)), attributes: bodyAttributes)) + remainingRange = NSMakeRange(range.location + 1, remainingRange.length - 1) + } + } else { + result.append(NSAttributedString(string: nsString.substring(with: NSMakeRange(remainingRange.location, 1)), attributes: bodyAttributes)) + remainingRange = NSMakeRange(range.location + 1, remainingRange.length - 1) + } } } else { if remainingRange.length != 0 { @@ -96,3 +119,16 @@ private func parseLink(string: NSString, remainingRange: inout NSRange) -> (text } return nil } + +private func parseBold(string: NSString, remainingRange: inout NSRange) -> String? { + var localRemainingRange = remainingRange + let closingRange = string.range(of: "**", options: [], range: localRemainingRange) + if closingRange.location != NSNotFound { + localRemainingRange = NSMakeRange(closingRange.location + closingRange.length, remainingRange.location + remainingRange.length - (closingRange.location + closingRange.length)) + + let result = string.substring(with: NSRange(location: remainingRange.location, length: closingRange.location - remainingRange.location)) + remainingRange = localRemainingRange + return result + } + return nil +} diff --git a/TelegramUI/MediaManager.swift b/TelegramUI/MediaManager.swift index 5f24db2f59..0e498a5dab 100644 --- a/TelegramUI/MediaManager.swift +++ b/TelegramUI/MediaManager.swift @@ -204,7 +204,7 @@ final class MediaManager: NSObject { self.globalControlsStatusDisposable.dispose() } - func videoContext(account: Account, id: ManagedMediaId, resource: MediaResource) -> Signal { + func videoContext(account: Account, id: ManagedMediaId, resource: MediaResource, preferSoftwareDecoding: Bool, backgroundThread: Bool) -> Signal { return Signal { subscriber in let disposable = MetaDisposable() @@ -214,8 +214,9 @@ final class MediaManager: NSObject { if let currentActiveContext = self.managedVideoContexts[wrappedId] { activeContext = currentActiveContext } else { - let mediaPlayer = MediaPlayer(audioSessionManager: self.audioSession, postbox: account.postbox, resource: resource, streamable: false) - let playerNode = MediaPlayerNode() + let mediaPlayer = MediaPlayer(audioSessionManager: self.audioSession, postbox: account.postbox, resource: resource, streamable: false, video: true, preferSoftwareDecoding: preferSoftwareDecoding, enableSound: false) + mediaPlayer.actionAtEnd = .loop + let playerNode = MediaPlayerNode(backgroundThread: backgroundThread) mediaPlayer.attachPlayerNode(playerNode) activeContext = ActiveManagedVideoContext(context: ManagedVideoContext(mediaPlayer: mediaPlayer, playerNode: playerNode)) self.managedVideoContexts[wrappedId] = activeContext diff --git a/TelegramUI/MediaNavigationAccessoryItemListNode.swift b/TelegramUI/MediaNavigationAccessoryItemListNode.swift index 4b462eaf8a..d5ab88872a 100644 --- a/TelegramUI/MediaNavigationAccessoryItemListNode.swift +++ b/TelegramUI/MediaNavigationAccessoryItemListNode.swift @@ -64,7 +64,8 @@ final class MediaNavigationAccessoryItemListNode: ASDisplayNode { } } } - }, openSecretMessagePreview: { _ in }, closeSecretMessagePreview: { }, openPeer: { _ in }, openPeerMention: { _ in }, openMessageContextMenu: { _ in }, navigateToMessage: { _ in }, clickThroughMessage: { }, toggleMessageSelection: { _ in }, sendMessage: { _ in }, sendSticker: { _ in }, requestMessageActionCallback: { _ in }, openUrl: { _ in }, shareCurrentLocation: {}, shareAccountContact: {}, sendBotCommand: { _, _ in }, openInstantPage: { _ in }, openHashtag: { _ in }, updateInputState: { _ in }) + }, openSecretMessagePreview: { _ in }, closeSecretMessagePreview: { }, openPeer: { _ in }, openPeerMention: { _ in }, openMessageContextMenu: { _ in }, navigateToMessage: { _ in }, clickThroughMessage: { }, toggleMessageSelection: { _ in }, sendMessage: { _ in }, sendSticker: { _ in }, sendGif: { _ in }, requestMessageActionCallback: { _ in }, openUrl: { _ in }, shareCurrentLocation: {}, shareAccountContact: {}, sendBotCommand: { _, _ in }, openInstantPage: { _ in }, openHashtag: { _ in }, updateInputState: { _ in }, openMessageShareMenu: { _ in + }, presentController: { _ in }) let listNode = ChatHistoryListNode(account: account, peerId: updatedPlaylistPeerId, tagMask: .Music, messageId: nil, controllerInteraction: controllerInteraction, mode: .list) listNode.preloadPages = true diff --git a/TelegramUI/MediaPlayer.swift b/TelegramUI/MediaPlayer.swift index 3d4b817249..e5f3aaa043 100644 --- a/TelegramUI/MediaPlayer.swift +++ b/TelegramUI/MediaPlayer.swift @@ -5,6 +5,8 @@ import CoreMedia import TelegramCore import Postbox +private let traceEvents = false + private struct MediaPlayerControlTimebase { let timebase: CMTimebase let isAudio: Bool @@ -40,9 +42,13 @@ private final class MediaPlayerContext { private let postbox: Postbox private let resource: MediaResource private let streamable: Bool + private let video: Bool + private let preferSoftwareDecoding: Bool + private var enableSound: Bool private var state: MediaPlayerState = .empty private var audioRenderer: MediaPlayerAudioRenderer? + fileprivate let videoRenderer: VideoPlayerProxy private var tickTimer: SwiftSignalKit.Timer? @@ -51,29 +57,7 @@ private final class MediaPlayerContext { fileprivate var actionAtEnd: MediaPlayerActionAtEnd = .stop - fileprivate var playerNode: MediaPlayerNode? { - didSet { - if let playerNode = self.playerNode { - var controlTimebase: CMTimebase? - - switch self.state { - case let .paused(loadedState): - controlTimebase = loadedState.controlTimebase.timebase - case let .playing(loadedState): - controlTimebase = loadedState.controlTimebase.timebase - case .empty, .seeking: - break - } - if let controlTimebase = controlTimebase { - DispatchQueue.main.async { - playerNode.controlTimebase = controlTimebase - } - } - } - } - } - - init(queue: Queue, audioSessionManager: ManagedAudioSession, playerStatus: ValuePromise, postbox: Postbox, resource: MediaResource, streamable: Bool) { + init(queue: Queue, audioSessionManager: ManagedAudioSession, playerStatus: ValuePromise, postbox: Postbox, resource: MediaResource, streamable: Bool, video: Bool, preferSoftwareDecoding: Bool, enableSound: Bool) { assert(queue.isCurrent()) self.queue = queue @@ -82,6 +66,70 @@ private final class MediaPlayerContext { self.postbox = postbox self.resource = resource self.streamable = streamable + self.video = video + self.preferSoftwareDecoding = preferSoftwareDecoding + self.enableSound = enableSound + + self.videoRenderer = VideoPlayerProxy(queue: queue) + + self.videoRenderer.visibilityUpdated = { [weak self] value in + assert(queue.isCurrent()) + + if let strongSelf = self { + switch strongSelf.state { + case .empty: + if value { + strongSelf.play() + } + case .paused: + if value { + strongSelf.play() + } + case .playing: + if !value { + strongSelf.pause() + } + case let .seeking(_, _, _, action): + switch action { + case .pause: + if value { + strongSelf.play() + } + case .play: + if !value { + strongSelf.pause() + } + } + } + } + } + + self.videoRenderer.takeFrameAndQueue = (queue, { [weak self] in + assert(queue.isCurrent()) + + if let strongSelf = self { + var maybeLoadedState: MediaPlayerLoadedState? + + switch strongSelf.state { + case .empty: + return .noFrames + case let .paused(state): + maybeLoadedState = state + case let .playing(state): + maybeLoadedState = state + case .seeking: + return .noFrames + } + + if let loadedState = maybeLoadedState, let videoBuffer = loadedState.mediaBuffers.videoBuffer { + return videoBuffer.takeFrame() + } else { + return .noFrames + } + } else { + return .noFrames + } + }) } deinit { @@ -156,7 +204,7 @@ private final class MediaPlayerContext { self.playerStatus.set(status) } - let frameSource = FFMpegMediaFrameSource(queue: self.queue, postbox: self.postbox, resource: self.resource, streamable: self.streamable) + let frameSource = FFMpegMediaFrameSource(queue: self.queue, postbox: self.postbox, resource: self.resource, streamable: self.streamable, video: self.video, preferSoftwareDecoding: self.preferSoftwareDecoding) let disposable = MetaDisposable() self.state = .seeking(frameSource: frameSource, timestamp: timestamp, disposable: disposable, action: action) @@ -171,7 +219,9 @@ private final class MediaPlayerContext { } fileprivate func seekingCompleted(seekResult: MediaFrameSourceSeekResult) { - print("seekingCompleted at \(CMTimeGetSeconds(seekResult.timestamp))") + if traceEvents { + print("seekingCompleted at \(CMTimeGetSeconds(seekResult.timestamp))") + } assert(self.queue.isCurrent()) @@ -180,15 +230,20 @@ private final class MediaPlayerContext { return } - seekResult.buffers.audioBuffer?.statusUpdated = { [weak self] in + var buffers = seekResult.buffers + if !self.enableSound { + buffers = MediaPlaybackBuffers(audioBuffer: nil, videoBuffer: buffers.videoBuffer) + } + + buffers.audioBuffer?.statusUpdated = { [weak self] in self?.tick() } - seekResult.buffers.videoBuffer?.statusUpdated = { [weak self] in + buffers.videoBuffer?.statusUpdated = { [weak self] in self?.tick() } let controlTimebase: MediaPlayerControlTimebase - if let _ = seekResult.buffers.audioBuffer { + if let _ = buffers.audioBuffer { let renderer: MediaPlayerAudioRenderer if let currentRenderer = self.audioRenderer { renderer = currentRenderer @@ -216,73 +271,36 @@ private final class MediaPlayerContext { CMTimebaseSetTime(timebase!, seekResult.timestamp) } - let loadedState = MediaPlayerLoadedState(frameSource: frameSource, mediaBuffers: seekResult.buffers, controlTimebase: controlTimebase) + let loadedState = MediaPlayerLoadedState(frameSource: frameSource, mediaBuffers: buffers, controlTimebase: controlTimebase) if let audioRenderer = self.audioRenderer { let queue = self.queue audioRenderer.flushBuffers(at: seekResult.timestamp, completion: { [weak self] in queue.async { [weak self] in if let strongSelf = self { - if let playerNode = strongSelf.playerNode { - let queue = strongSelf.queue - - DispatchQueue.main.async { - playerNode.reset() - playerNode.controlTimebase = controlTimebase.timebase - - queue.async { [weak self] in - if let strongSelf = self { - switch action { - case .play: - strongSelf.state = .playing(loadedState) - strongSelf.audioRenderer?.start() - case .pause: - strongSelf.state = .paused(loadedState) - } - - strongSelf.lastStatusUpdateTimestamp = nil - strongSelf.tick() - } - } - } - } else { - switch action { - case .play: - strongSelf.state = .playing(loadedState) - strongSelf.audioRenderer?.start() - case .pause: - strongSelf.state = .paused(loadedState) - } - - strongSelf.lastStatusUpdateTimestamp = nil - strongSelf.tick() + switch action { + case .play: + strongSelf.state = .playing(loadedState) + strongSelf.audioRenderer?.start() + case .pause: + strongSelf.state = .paused(loadedState) } + + strongSelf.lastStatusUpdateTimestamp = nil + strongSelf.tick() } } }) } else { - if let playerNode = self.playerNode { - let queue = self.queue - - DispatchQueue.main.async { - playerNode.reset() - playerNode.controlTimebase = controlTimebase.timebase - - queue.async { [weak self] in - if let strongSelf = self { - switch action { - case .play: - strongSelf.state = .playing(loadedState) - case .pause: - strongSelf.state = .paused(loadedState) - } - - strongSelf.lastStatusUpdateTimestamp = nil - strongSelf.tick() - } - } - } + switch action { + case .play: + self.state = .playing(loadedState) + case .pause: + self.state = .paused(loadedState) } + + self.lastStatusUpdateTimestamp = nil + self.tick() } } @@ -364,7 +382,9 @@ private final class MediaPlayerContext { } let timestamp = CMTimeGetSeconds(CMTimebaseGetTime(loadedState.controlTimebase.timebase)) - print("tick at \(timestamp)") + if traceEvents { + print("tick at \(timestamp)") + } var duration: Double = 0.0 var videoStatus: MediaTrackFrameBufferStatus? @@ -429,7 +449,7 @@ private final class MediaPlayerContext { let nextTickDelay = max(0.0, fullUntil - timestamp) let tickTimer = SwiftSignalKit.Timer(timeout: nextTickDelay, repeat: false, completion: { [weak self] in self?.tick() - }, queue: self.queue) + }, queue: self.queue) self.tickTimer = tickTimer tickTimer.start() } else { @@ -446,7 +466,7 @@ private final class MediaPlayerContext { let tickTimer = SwiftSignalKit.Timer(timeout: nextTickDelay, repeat: false, completion: { [weak self] in self?.tick() - }, queue: self.queue) + }, queue: self.queue) self.tickTimer = tickTimer tickTimer.start() } else { @@ -466,15 +486,16 @@ private final class MediaPlayerContext { } } - if let playerNode = self.playerNode, let videoTrackFrameBuffer = loadedState.mediaBuffers.videoBuffer, videoTrackFrameBuffer.hasFrames { - let queue = self.queue.queue + if let videoTrackFrameBuffer = loadedState.mediaBuffers.videoBuffer, videoTrackFrameBuffer.hasFrames { + self.videoRenderer.state = (loadedState.controlTimebase.timebase, true, videoTrackFrameBuffer.rotationAngle) + /*let queue = self.queue.queue playerNode.beginRequestingFrames(queue: queue, takeFrame: { [weak videoTrackFrameBuffer] in if let videoTrackFrameBuffer = videoTrackFrameBuffer { return videoTrackFrameBuffer.takeFrame() } else { return .noFrames } - }) + })*/ } if let audioRenderer = self.audioRenderer, let audioTrackFrameBuffer = loadedState.mediaBuffers.audioBuffer, audioTrackFrameBuffer.hasFrames { @@ -594,9 +615,9 @@ final class MediaPlayer { } } - init(audioSessionManager: ManagedAudioSession, postbox: Postbox, resource: MediaResource, streamable: Bool) { + init(audioSessionManager: ManagedAudioSession, postbox: Postbox, resource: MediaResource, streamable: Bool, video: Bool, preferSoftwareDecoding: Bool, enableSound: Bool) { self.queue.async { - let context = MediaPlayerContext(queue: self.queue, audioSessionManager: audioSessionManager, playerStatus: self.statusValue, postbox: postbox, resource: resource, streamable: streamable) + let context = MediaPlayerContext(queue: self.queue, audioSessionManager: audioSessionManager, playerStatus: self.statusValue, postbox: postbox, resource: resource, streamable: streamable, video: video, preferSoftwareDecoding: preferSoftwareDecoding, enableSound: enableSound) self.contextRef = Unmanaged.passRetained(context) } } @@ -641,10 +662,14 @@ final class MediaPlayer { } func attachPlayerNode(_ node: MediaPlayerNode) { + let nodeRef: Unmanaged = Unmanaged.passRetained(node) self.queue.async { if let context = self.contextRef?.takeUnretainedValue() { - node.queue = self.queue - context.playerNode = node + context.videoRenderer.attachNodeAndRelease(nodeRef) + } else { + Queue.mainQueue().async { + nodeRef.release() + } } } } diff --git a/TelegramUI/MediaPlayerAudioRenderer.swift b/TelegramUI/MediaPlayerAudioRenderer.swift index 4b362d4f41..b1aff80333 100644 --- a/TelegramUI/MediaPlayerAudioRenderer.swift +++ b/TelegramUI/MediaPlayerAudioRenderer.swift @@ -352,7 +352,6 @@ private final class AudioPlayerRendererContext { self.closeAudioUnit() return } - } } @@ -453,7 +452,7 @@ private final class AudioPlayerRendererContext { case .skipFrame: self.checkBuffer() break - case .noFrames: + case .noFrames, .finished: self.requestingFramesContext = nil } } diff --git a/TelegramUI/MediaPlayerNode.swift b/TelegramUI/MediaPlayerNode.swift index e6168fe090..c09079f374 100644 --- a/TelegramUI/MediaPlayerNode.swift +++ b/TelegramUI/MediaPlayerNode.swift @@ -3,14 +3,57 @@ import UIKit import AsyncDisplayKit import SwiftSignalKit -private final class MediaPlayerNodeDisplayView: UIView { - override class var layerClass: AnyClass { - return AVSampleBufferDisplayLayer.self +private final class MediaPlayerNodeLayer: AVSampleBufferDisplayLayer { + override func action(forKey event: String) -> CAAction? { + return NSNull() + } +} + +private final class MediaPlayerNodeDisplayNode: ASDisplayNode { + var updateInHierarchy: ((Bool) -> Void)? + + override init() { + super.init() + self.isLayerBacked = true + } + + override func willEnterHierarchy() { + super.willEnterHierarchy() + + self.updateInHierarchy?(true) + } + + override func didExitHierarchy() { + super.didExitHierarchy() + + self.updateInHierarchy?(false) + } +} + +private enum PollStatus: CustomStringConvertible { + case delay(Double) + case finished + + var description: String { + switch self { + case let .delay(value): + return "delay(\(value))" + case .finished: + return "finished" + } } } final class MediaPlayerNode: ASDisplayNode { - private var displayView: MediaPlayerNodeDisplayView? + var videoInHierarchy: Bool = false + var updateVideoInHierarchy: ((Bool) -> Void)? + + private var videoNode: MediaPlayerNodeDisplayNode + + private var videoLayer: AVSampleBufferDisplayLayer? + + private let videoQueue: Queue + var snapshotNode: ASDisplayNode? { didSet { if let snapshotNode = oldValue { @@ -24,31 +67,185 @@ final class MediaPlayerNode: ASDisplayNode { } } - var controlTimebase: CMTimebase? { - get { - return (self.displayView?.layer as? AVSampleBufferDisplayLayer)?.controlTimebase - } set(value) { - (self.displayView?.layer as? AVSampleBufferDisplayLayer)?.controlTimebase = value + var takeFrameAndQueue: (Queue, () -> MediaTrackFrameResult)? + var timer: SwiftSignalKit.Timer? + var polling = false + + var currentRotationAngle = 0.0 + + var state: (timebase: CMTimebase, requestFrames: Bool, rotationAngle: Double)? { + didSet { + self.updateState() + } + } + + private func updateState() { + if let (timebase, requestFrames, rotationAngle) = self.state { + if let videoLayer = self.videoLayer { + videoQueue.async { + if videoLayer.controlTimebase !== timebase { + //self.videoNode.playerLayer.flush() + videoLayer.controlTimebase = timebase + } + } + + if !self.currentRotationAngle.isEqual(to: rotationAngle) { + self.currentRotationAngle = rotationAngle + videoLayer.setAffineTransform(CGAffineTransform(rotationAngle: CGFloat(rotationAngle))) + } + + if requestFrames { + //print("request") + self.startPolling() + } + } + } + } + + private func startPolling() { + if !self.polling { + self.polling = true + self.poll(completion: { [weak self] status in + self?.polling = false + + if let strongSelf = self, let (_, requestFrames, _) = strongSelf.state, requestFrames { + strongSelf.timer?.invalidate() + switch status { + case let .delay(delay): + strongSelf.timer = SwiftSignalKit.Timer(timeout: delay, repeat: true, completion: { + if let strongSelf = self, let videoLayer = strongSelf.videoLayer, let (_, requestFrames, _) = strongSelf.state, requestFrames { + if videoLayer.isReadyForMoreMediaData { + strongSelf.timer?.invalidate() + strongSelf.timer = nil + strongSelf.startPolling() + } + } + }, queue: Queue.mainQueue()) + strongSelf.timer?.start() + case .finished: + break + } + } + }) + } + } + + private func poll(completion: @escaping (PollStatus) -> Void) { + if let (takeFrameQueue, takeFrame) = self.takeFrameAndQueue, let videoLayer = self.videoLayer, let (timebase, _, _) = self.state { + let layerRef = Unmanaged.passRetained(videoLayer) + takeFrameQueue.async { + let status: PollStatus + do { + var numFrames = 0 + let layer = layerRef.takeUnretainedValue() + let layerTime = CMTimeGetSeconds(CMTimebaseGetTime(timebase)) + var maxTakenTime = layerTime + 0.1 + var finised = false + loop: while true { + let isReady = layer.isReadyForMoreMediaData + + if isReady { + switch takeFrame() { + case let .frame(frame): + numFrames += 1 + let frameTime = CMTimeGetSeconds(frame.position) + if frame.resetDecoder { + layer.flush() + } + + if frame.decoded && frameTime < layerTime { + //print("drop frame at \(frameTime) current \(layerTime)") + continue loop + } + + //print("took frame at \(frameTime) current \(layerTime)") + maxTakenTime = frameTime + layer.enqueue(frame.sampleBuffer) + case .skipFrame: + break + case .noFrames: + finised = true + break loop + case .finished: + finised = true + break loop + } + } else { + break loop + } + } + if finised { + status = .finished + } else { + status = .delay(max(1.0 / 30.0, maxTakenTime - layerTime)) + } + //print("took \(numFrames) frames, status \(status)") + } + DispatchQueue.main.async { + layerRef.release() + + completion(status) + } + } } } - var queue: Queue? - private var isRequestingFrames = false var transformArguments: TransformImageArguments? { didSet { + var cornerRadius: CGFloat = 0.0 + if let transformArguments = self.transformArguments { + cornerRadius = transformArguments.corners.bottomLeft.radius + } + if !self.cornerRadius.isEqual(to: cornerRadius) { + self.cornerRadius = cornerRadius + self.clipsToBounds = !cornerRadius.isZero + } else { + if let transformArguments = self.transformArguments { + self.clipsToBounds = !cornerRadius.isZero || (transformArguments.imageSize.width > transformArguments.boundingSize.width || transformArguments.imageSize.height > transformArguments.boundingSize.height) + } + } self.updateLayout() } } - override init() { + init(backgroundThread: Bool = false) { + self.videoNode = MediaPlayerNodeDisplayNode() + + if false && backgroundThread { + self.videoQueue = Queue() + } else { + self.videoQueue = Queue.mainQueue() + } + super.init() - self.displayView = MediaPlayerNodeDisplayView() - self.view.addSubview(self.displayView!) + self.videoNode.updateInHierarchy = { [weak self] value in + if let strongSelf = self { + if strongSelf.videoInHierarchy != value { + strongSelf.videoInHierarchy = value + //strongSelf.videoNode.playerLayer.flush() + } + strongSelf.updateVideoInHierarchy?(value) + } + } + self.addSubnode(self.videoNode) + + self.videoQueue.async { [weak self] in + let videoLayer = MediaPlayerNodeLayer() + Queue.mainQueue().async { + if let strongSelf = self { + strongSelf.videoLayer = videoLayer + strongSelf.updateLayout() + + strongSelf.layer.addSublayer(videoLayer) + strongSelf.updateState() + } + } + } } deinit { - //assert(Queue.mainQueue().isCurrent()) + assert(Queue.mainQueue().isCurrent()) } override var frame: CGRect { @@ -78,46 +275,14 @@ final class MediaPlayerNode: ASDisplayNode { fittedRect = bounds } - self.displayView?.frame = fittedRect + if let videoLayer = self.videoLayer { + videoLayer.position = CGPoint(x: fittedRect.midX, y: fittedRect.midY) + videoLayer.bounds = CGRect(origin: CGPoint(), size: fittedRect.size) + } self.snapshotNode?.frame = fittedRect } func reset() { - (self.displayView?.layer as? AVSampleBufferDisplayLayer)?.flush() - } - - func beginRequestingFrames(queue: DispatchQueue, takeFrame: @escaping () -> MediaTrackFrameResult) { - assert(self.queue != nil && self.queue!.isCurrent()) - - if isRequestingFrames { - return - } - isRequestingFrames = true - //print("begin requesting") - - (self.displayView?.layer as? AVSampleBufferDisplayLayer)?.requestMediaDataWhenReady(on: queue, using: { [weak self] in - if let strongSelf = self, let layer = strongSelf.displayView?.layer as? AVSampleBufferDisplayLayer { - loop: while layer.isReadyForMoreMediaData { - switch takeFrame() { - case let .frame(frame): - if frame.resetDecoder { - layer.flush() - } - layer.enqueue(frame.sampleBuffer) - case .skipFrame: - break - case .noFrames: - if let strongSelf = self, strongSelf.isRequestingFrames { - strongSelf.isRequestingFrames = false - if let layer = (strongSelf.displayView?.layer as? AVSampleBufferDisplayLayer) { - layer.stopRequestingMediaData() - } - //print("stop requesting") - } - break loop - } - } - } - }) + self.videoLayer?.flush() } } diff --git a/TelegramUI/MediaTrackFrame.swift b/TelegramUI/MediaTrackFrame.swift index e48bd4edb2..7dd8f0b19d 100644 --- a/TelegramUI/MediaTrackFrame.swift +++ b/TelegramUI/MediaTrackFrame.swift @@ -5,11 +5,15 @@ final class MediaTrackFrame { let type: MediaTrackFrameType let sampleBuffer: CMSampleBuffer let resetDecoder: Bool + let decoded: Bool + let rotationAngle: Double - init(type: MediaTrackFrameType, sampleBuffer: CMSampleBuffer, resetDecoder: Bool) { + init(type: MediaTrackFrameType, sampleBuffer: CMSampleBuffer, resetDecoder: Bool, decoded: Bool, rotationAngle: Double = 0.0) { self.type = type self.sampleBuffer = sampleBuffer self.resetDecoder = resetDecoder + self.decoded = decoded + self.rotationAngle = rotationAngle } var position: CMTime { diff --git a/TelegramUI/MediaTrackFrameBuffer.swift b/TelegramUI/MediaTrackFrameBuffer.swift index d3ce979502..b19c55658a 100644 --- a/TelegramUI/MediaTrackFrameBuffer.swift +++ b/TelegramUI/MediaTrackFrameBuffer.swift @@ -12,8 +12,11 @@ enum MediaTrackFrameResult { case noFrames case skipFrame case frame(MediaTrackFrame) + case finished } +private let traceEvents = false + final class MediaTrackFrameBuffer { private let stallDuration: Double = 1.0 private let lowWaterDuration: Double = 2.0 @@ -23,6 +26,7 @@ final class MediaTrackFrameBuffer { private let decoder: MediaTrackFrameDecoder private let type: MediaTrackFrameType let duration: CMTime + let rotationAngle: Double var statusUpdated: () -> Void = { } @@ -32,11 +36,12 @@ final class MediaTrackFrameBuffer { private var endOfStream = false private var bufferedUntilTime: CMTime? - init(frameSource: MediaFrameSource, decoder: MediaTrackFrameDecoder, type: MediaTrackFrameType, duration: CMTime) { + init(frameSource: MediaFrameSource, decoder: MediaTrackFrameDecoder, type: MediaTrackFrameType, duration: CMTime, rotationAngle: Double) { self.frameSource = frameSource self.type = type self.decoder = decoder self.duration = duration + self.rotationAngle = rotationAngle self.frameSourceSinkIndex = self.frameSource.addEventSink { [weak self] event in if let strongSelf = self { @@ -76,7 +81,9 @@ final class MediaTrackFrameBuffer { } if let maxUntilTime = maxUntilTime { - print("added \(frames.count) frames until \(CMTimeGetSeconds(maxUntilTime)), \(self.frames.count) total") + if traceEvents { + print("added \(frames.count) frames until \(CMTimeGetSeconds(maxUntilTime)), \(self.frames.count) total") + } } self.statusUpdated() @@ -97,18 +104,31 @@ final class MediaTrackFrameBuffer { bufferedDuration = CMTimeGetSeconds(bufferedUntilTime) - timestamp } + let minTimestamp = timestamp - 1.0 + for i in (0 ..< self.frames.count).reversed() { + if CMTimeGetSeconds(self.frames[i].pts) < minTimestamp { + self.frames.remove(at: i) + } + } + if bufferedDuration < self.lowWaterDuration { - print("buffered duration: \(bufferedDuration), requesting until \(timestamp) + \(self.highWaterDuration - bufferedDuration)") + if traceEvents { + print("buffered duration: \(bufferedDuration), requesting until \(timestamp) + \(self.highWaterDuration - bufferedDuration)") + } self.frameSource.generateFrames(until: timestamp + self.highWaterDuration) if bufferedDuration > self.stallDuration { - print("buffered1 duration: \(bufferedDuration), wait until \(timestamp) + \(self.highWaterDuration - bufferedDuration)") + if traceEvents { + print("buffered1 duration: \(bufferedDuration), wait until \(timestamp) + \(self.highWaterDuration - bufferedDuration)") + } return .full(until: timestamp + self.highWaterDuration) } else { return .buffering } } else { - print("buffered2 duration: \(bufferedDuration), wait until \(timestamp) + \(bufferedDuration - self.lowWaterDuration)") + if traceEvents { + print("buffered2 duration: \(bufferedDuration), wait until \(timestamp) + \(bufferedDuration - self.lowWaterDuration)") + } return .full(until: timestamp + max(0.0, bufferedDuration - self.lowWaterDuration)) } } @@ -125,6 +145,16 @@ final class MediaTrackFrameBuffer { } else { return .skipFrame } + } else { + if self.endOfStream, let decodedFrame = self.decoder.takeRemainingFrame() { + return .frame(decodedFrame) + } else { + if let bufferedUntilTime = bufferedUntilTime { + if CMTimeCompare(bufferedUntilTime, self.duration) >= 0 || self.endOfStream { + return .finished + } + } + } } return .noFrames } diff --git a/TelegramUI/MediaTrackFrameDecoder.swift b/TelegramUI/MediaTrackFrameDecoder.swift index 25e45f2e19..9e4c8266ec 100644 --- a/TelegramUI/MediaTrackFrameDecoder.swift +++ b/TelegramUI/MediaTrackFrameDecoder.swift @@ -1,5 +1,6 @@ protocol MediaTrackFrameDecoder { func decode(frame: MediaTrackDecodableFrame) -> MediaTrackFrame? + func takeRemainingFrame() -> MediaTrackFrame? func reset() } diff --git a/TelegramUI/MultiplexedSoftwareVideoNode.swift b/TelegramUI/MultiplexedSoftwareVideoNode.swift new file mode 100644 index 0000000000..15858d3fba --- /dev/null +++ b/TelegramUI/MultiplexedSoftwareVideoNode.swift @@ -0,0 +1,687 @@ +import Foundation +import UIKit +import GLKit +import OpenGLES +import Display +import SwiftSignalKit +import AsyncDisplayKit +import Postbox +import TelegramCore + +private final class MultiplexedSoftwareVideoTrackingNode: ASDisplayNode { + var inHierarchyUpdated: ((Bool) -> Void)? + + override func willEnterHierarchy() { + super.willEnterHierarchy() + + self.inHierarchyUpdated?(true) + } + + override func didExitHierarchy() { + super.didExitHierarchy() + + self.inHierarchyUpdated?(false) + } +} + +private final class VisibleVideoItem { + let file: TelegramMediaFile + let frame: CGRect + + init(file: TelegramMediaFile, frame: CGRect) { + self.file = file + self.frame = frame + } +} + +private enum UniformIndex: Int { + case Y = 0 + case UV + case RotationAngle + case ColorConversionMatrix +} + +private enum AttributeIndex: GLuint { + case Vertex = 0 + case TextureCoordinates + case NumAttributes +} + +private let colorConversion601: [GLfloat] = [ + 1.164, 1.164, 1.164, + 0.0, -0.392, 2.017, + 1.596, -0.813, 0.0 +] + +private let colorConversion709: [GLfloat] = [ + 1.164, 1.164, 1.164, + 0.0, -0.213, 2.112, + 1.793, -0.533, 0.0 +] + +private let fragmentShaderSource: Data = { + return try! Data(contentsOf: URL(fileURLWithPath: Bundle.main.path(forResource: "VTPlayer/VTPlayer_Shader", ofType: "fsh")!)) +}() + +private let vertexShaderSource: Data = { + return try! Data(contentsOf: URL(fileURLWithPath: Bundle.main.path(forResource: "VTPlayer/VTPlayer_Shader", ofType: "vsh")!)) +}() + +private final class SoftwareVideoFrames { + var frames: [MediaId: MediaTrackFrame] = [:] +} + +final class MultiplexedSoftwareVideoNode: UIView { + override class var layerClass: AnyClass { + return CAEAGLLayer.self + } + + private var eglLayer: CAEAGLLayer { + return self.layer as! CAEAGLLayer + } + + private let account: Account + private let scrollView: UIScrollView + private let trackingNode: MultiplexedSoftwareVideoTrackingNode + + private let context: EAGLContext + private var uniforms: [GLint] + private var program: GLuint = 0 + private var videoTextureCache: CVOpenGLESTextureCache? + + private var drawableSize: CGSize? + + private var frameBufferHandle: GLuint? + private var colorBufferHandle: GLuint? + + private var displayLink: CADisplayLink! + + var files: [TelegramMediaFile] = [] { + didSet { + self.updateVisibleItems() + } + } + private var displayItems: [VisibleVideoItem] = [] + private let sourceManager: MultiplexedSoftwareVideoSourceManager + + private let videoSourceQueue = Queue() + + init(account: Account, scrollView: UIScrollView) { + self.account = account + self.scrollView = scrollView + self.trackingNode = MultiplexedSoftwareVideoTrackingNode() + self.sourceManager = MultiplexedSoftwareVideoSourceManager(queue: self.videoSourceQueue, account: account) + + self.context = EAGLContext(api: .openGLES2) + + var videoTextureCache: CVOpenGLESTextureCache? + CVOpenGLESTextureCacheCreate(kCFAllocatorDefault, nil, self.context, nil, &videoTextureCache) + self.videoTextureCache = videoTextureCache + + self.uniforms = Array(repeating: 0, count: 4) + + super.init(frame: CGRect()) + + self.isUserInteractionEnabled = false + self.layer.contentsScale = 2.0 + self.isOpaque = true + + self.addSubnode(self.trackingNode) + + self.eglLayer.drawableProperties = [ + kEAGLDrawablePropertyRetainedBacking: false as NSNumber, + kEAGLDrawablePropertyColorFormat: kEAGLColorFormatRGBA8 + ] + + EAGLContext.setCurrent(self.context) + + glDisable(GLenum(GL_DEPTH_TEST)) + + glEnableVertexAttribArray(AttributeIndex.Vertex.rawValue) + glVertexAttribPointer(AttributeIndex.Vertex.rawValue, 2, GLenum(GL_FLOAT), GLboolean(GL_FALSE), GLsizei(2 * MemoryLayout.size), nil) + + glEnableVertexAttribArray(AttributeIndex.TextureCoordinates.rawValue) + + glVertexAttribPointer(AttributeIndex.TextureCoordinates.rawValue, 2, GLenum(GL_FLOAT), GLboolean(GL_FALSE), GLsizei(2 * MemoryLayout.size), nil) + + self.loadShaders() + + glUseProgram(self.program) + + // 0 and 1 are the texture IDs of lumaTexture and chromaTexture respectively. + glUniform1i(self.uniforms[UniformIndex.Y.rawValue], 0) + glUniform1i(self.uniforms[UniformIndex.UV.rawValue], 1) + + glUniform1f(self.uniforms[UniformIndex.RotationAngle.rawValue], 0.0) + + glUniformMatrix3fv(self.uniforms[UniformIndex.ColorConversionMatrix.rawValue], 1, GLboolean(GL_FALSE), colorConversion709) + + class DisplayLinkProxy: NSObject { + weak var target: MultiplexedSoftwareVideoNode? + init(target: MultiplexedSoftwareVideoNode) { + self.target = target + } + + @objc func displayLinkEvent() { + self.target?.displayLinkEvent() + } + } + + self.displayLink = CADisplayLink(target: DisplayLinkProxy(target: self), selector: #selector(DisplayLinkProxy.displayLinkEvent)) + self.displayLink.add(to: RunLoop.main, forMode: RunLoopMode.commonModes) + if #available(iOS 10.0, *) { + self.displayLink.preferredFramesPerSecond = 60 + } + self.displayLink.isPaused = true + + self.trackingNode.inHierarchyUpdated = { [weak self] value in + if let strongSelf = self { + strongSelf.displayLink.isPaused = !value + } + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + assert(Queue.mainQueue().isCurrent()) + + if var frameBufferHandle = self.frameBufferHandle { + glDeleteFramebuffers(1, &frameBufferHandle) + } + + if var colorBufferHandle = self.colorBufferHandle { + glDeleteFramebuffers(1, &colorBufferHandle) + } + } + + private func compileShaderWithType(shaderType: GLuint) -> GLuint? { + let source = shaderType == UInt32(GL_FRAGMENT_SHADER) ? fragmentShaderSource : vertexShaderSource + + let shader = glCreateShader(shaderType) + source.withUnsafeBytes { (bytes: UnsafePointer) -> Void in + var bytes: UnsafePointer? = bytes + var length = GLint(source.count) + withUnsafePointer(to: &bytes, { (bytesRef: UnsafePointer?>) -> Void in + glShaderSource(shader, 1, bytesRef, &length) + }) + } + + glCompileShader(shader) + + var logLength: GLsizei = 0 + glGetShaderInfoLog(shader, 0, &logLength, nil) + if logLength != 0 { + var log = Data(count: Int(logLength)) + + log.withUnsafeMutableBytes { (bytes: UnsafeMutablePointer) -> Void in + glGetShaderInfoLog(shader, logLength, &logLength, bytes) + } + + if let logString = String(data: log, encoding: .utf8) { + print("Shader log: \(logString)") + } + } + + return shader + } + + private func loadShaders() { + self.program = glCreateProgram() + + guard let vertShader = self.compileShaderWithType(shaderType: GLuint(GL_VERTEX_SHADER)) else { + return + } + + guard let fragShader = self.compileShaderWithType(shaderType: GLuint(GL_FRAGMENT_SHADER)) else { + return + } + + glAttachShader(self.program, vertShader) + glAttachShader(self.program, fragShader) + + glBindAttribLocation(self.program, AttributeIndex.Vertex.rawValue, "position") + glBindAttribLocation(self.program, AttributeIndex.TextureCoordinates.rawValue, "texCoord") + + glLinkProgram(self.program) + + var status: GLint = 0 + glGetProgramiv(self.program, GLenum(GL_LINK_STATUS), &status) + let ok = (status != 0) + if (ok) { + self.uniforms[UniformIndex.Y.rawValue] = glGetUniformLocation(self.program, "SamplerY") + self.uniforms[UniformIndex.UV.rawValue] = glGetUniformLocation(self.program, "SamplerUV") + self.uniforms[UniformIndex.RotationAngle.rawValue] = glGetUniformLocation(self.program, "preferredRotation") + self.uniforms[UniformIndex.ColorConversionMatrix.rawValue] = glGetUniformLocation(self.program, "colorConversionMatrix") + } + + glDetachShader(self.program, vertShader) + glDeleteShader(vertShader) + + glDetachShader(self.program, fragShader) + glDeleteShader(fragShader) + + if (!ok) { + glDeleteProgram(self.program) + self.program = 0 + } + + assert(ok) + } + + override func layoutSubviews() { + super.layoutSubviews() + + self.updateDrawable() + } + + private func displayLinkEvent() { + self.draw() + } + + private var validVisibleItemsOffset: CGFloat? + private func updateImmediatelyVisibleItems() { + let visibleBounds = self.scrollView.bounds + if let validVisibleItemsOffset = self.validVisibleItemsOffset, validVisibleItemsOffset.isEqual(to: visibleBounds.origin.y) { + return + } + self.validVisibleItemsOffset = visibleBounds.origin.y + let minVisibleY = visibleBounds.minY + let maxVisibleY = visibleBounds.maxY + + var visibleItems: [TelegramMediaFile] = [] + for item in self.displayItems { + if item.frame.maxY < minVisibleY { + continue; + } + if item.frame.minY > maxVisibleY { + break; + } + visibleItems.append(item.file) + } + + self.sourceManager.updateVisibleItems(visibleItems) + } + + private func draw() { + EAGLContext.setCurrent(self.context) + + let timestamp = CACurrentMediaTime() + + self.updateImmediatelyVisibleItems() + self.sourceManager.update(at: timestamp) + + if let drawableSize = self.drawableSize, let frameBufferHandle = self.frameBufferHandle, let colorBufferHandle = self.colorBufferHandle { + glBindFramebuffer(GLenum(GL_FRAMEBUFFER), frameBufferHandle) + + let backingWidth = GLint(drawableSize.width * 2.0) + let backingHeight = GLint(drawableSize.height * 2.0) + + glViewport(0, 0, backingWidth, backingHeight) + + glClearColor(1.0, 1.0, 1.0, 1.0) + glClear(GLbitfield(GL_COLOR_BUFFER_BIT)) + + glUseProgram(self.program) + glUniform1f(self.uniforms[UniformIndex.RotationAngle.rawValue], 0.0) + glUniformMatrix3fv(self.uniforms[UniformIndex.ColorConversionMatrix.rawValue], 1, GLboolean(GL_FALSE), colorConversion709) + + glEnableVertexAttribArray(AttributeIndex.Vertex.rawValue) + glEnableVertexAttribArray(AttributeIndex.TextureCoordinates.rawValue) + + let visibleBounds = self.scrollView.bounds + let minVisibleY = visibleBounds.minY + let maxVisibleY = visibleBounds.maxY + let verticalOffset = visibleBounds.origin.y + for item in self.displayItems { + if item.frame.maxY < minVisibleY { + continue; + } + if item.frame.minY > maxVisibleY { + break; + } + + if let videoFrame = self.sourceManager.immediateVideoFrames[item.file.fileId], let imageBuffer = CMSampleBufferGetImageBuffer(videoFrame.sampleBuffer) { + + let frameSize = CVImageBufferGetEncodedSize(imageBuffer) + let frameWidth = GLsizei(frameSize.width) + let frameHeight = GLsizei(frameSize.height) + + var lumaTextureRef: CVOpenGLESTexture? + var chromaTextureRef: CVOpenGLESTexture? + + glActiveTexture(GLenum(GL_TEXTURE0)) + CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault, self.videoTextureCache!, imageBuffer, nil, GLenum(GL_TEXTURE_2D), GL_RED_EXT, frameWidth, frameHeight, GLenum(GL_RED_EXT), GLenum(GL_UNSIGNED_BYTE), 0, &lumaTextureRef); + guard let lumaTexture = lumaTextureRef else { + print("error creating lumaTexture") + continue + } + + glBindTexture(CVOpenGLESTextureGetTarget(lumaTexture), CVOpenGLESTextureGetName(lumaTexture)) + glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MIN_FILTER), GL_LINEAR) + glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MAG_FILTER), GL_LINEAR) + glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_WRAP_S), GL_CLAMP_TO_EDGE) + glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_WRAP_T), GL_CLAMP_TO_EDGE) + + glActiveTexture(GLenum(GL_TEXTURE1)) + + CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault, self.videoTextureCache!, imageBuffer, nil, GLenum(GL_TEXTURE_2D), GL_RG_EXT, frameWidth / 2, frameHeight / 2, GLenum(GL_RG_EXT), GLenum(GL_UNSIGNED_BYTE), 1, &chromaTextureRef) + + guard let chromaTexture = chromaTextureRef else { + print("error creating chromaTexture") + continue + } + + glBindTexture(CVOpenGLESTextureGetTarget(chromaTexture), CVOpenGLESTextureGetName(chromaTexture)) + glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MIN_FILTER), GL_LINEAR) + glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MAG_FILTER), GL_LINEAR) + glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_WRAP_S), GL_CLAMP_TO_EDGE) + glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_WRAP_T), GL_CLAMP_TO_EDGE) + } + + let normalSize = item.frame.size + let normalOrigin = CGPoint(x: item.frame.origin.x, y: drawableSize.height - (item.frame.origin.y - verticalOffset) - normalSize.height) + + //let normalOrigin = CGPoint(x: 375.0 - 10.0, y: drawableSize.height - 100.0 - 10.0) + //let normalSize = CGSize(width: 10.0, height: 10.0) + + let normalizedSamplingSize = CGSize(width: normalSize.width / drawableSize.width, height: normalSize.height / drawableSize.height) + let normalizedOffset = CGPoint(x: -1.0 + normalOrigin.x * 2.0 / drawableSize.width + normalizedSamplingSize.width, y: -1.0 + normalOrigin.y * 2.0 / drawableSize.height + normalizedSamplingSize.height) + + let quadVertexData: [GLfloat] = [ + Float(normalizedOffset.x - 1.0 * normalizedSamplingSize.width), Float(normalizedOffset.y - 1.0 * normalizedSamplingSize.height), + Float(normalizedOffset.x + normalizedSamplingSize.width), Float(normalizedOffset.y - 1.0 * normalizedSamplingSize.height), + Float(normalizedOffset.x - 1.0 * normalizedSamplingSize.width), Float(normalizedOffset.y + normalizedSamplingSize.height), + Float(normalizedOffset.x + normalizedSamplingSize.width), Float(normalizedOffset.y + normalizedSamplingSize.height) + ] + + glVertexAttribPointer(AttributeIndex.Vertex.rawValue, 2, GLenum(GL_FLOAT), 0, 0, quadVertexData) + + let textureSamplingRect = CGRect(x: 0.0, y: 0.0, width: 1.0, height: 1.0) + let quadTextureData: [GLfloat] = [ + Float(textureSamplingRect.minX), Float(textureSamplingRect.maxY), + Float(textureSamplingRect.maxX), Float(textureSamplingRect.maxY), + Float(textureSamplingRect.minX), Float(textureSamplingRect.minY), + Float(textureSamplingRect.maxX), Float(textureSamplingRect.minY) + ] + glVertexAttribPointer(AttributeIndex.TextureCoordinates.rawValue, 2, GLenum(GL_FLOAT), 0, 0, quadTextureData); + + glDrawArrays(GLenum(GL_TRIANGLE_STRIP), 0, 4) + } + + glBindRenderbuffer(GLenum(GL_RENDERBUFFER), colorBufferHandle) + self.context.presentRenderbuffer(Int(GL_RENDERBUFFER)) + } + } + + private func updateDrawable() { + if !self.bounds.size.width.isZero { + if self.drawableSize == nil || self.drawableSize! != self.bounds.size { + self.drawableSize = self.bounds.size + + if var frameBufferHandle = self.frameBufferHandle { + glDeleteFramebuffers(1, &frameBufferHandle) + self.frameBufferHandle = nil + } + + if var colorBufferHandle = self.colorBufferHandle { + glDeleteFramebuffers(1, &colorBufferHandle) + self.colorBufferHandle = nil + } + + var frameBufferHandle: GLuint = 0 + glGenFramebuffers(1, &frameBufferHandle); + glBindFramebuffer(GLenum(GL_FRAMEBUFFER), frameBufferHandle) + + var colorBufferHandle: GLuint = 0 + glGenRenderbuffers(1, &colorBufferHandle) + glBindRenderbuffer(GLenum(GL_RENDERBUFFER), colorBufferHandle) + self.context.renderbufferStorage(Int(GL_RENDERBUFFER), from: self.eglLayer) + + var backingWidth = GLint(self.bounds.size.width * 2.0) + var backingHeight = GLint(self.bounds.size.height * 2.0) + glGetRenderbufferParameteriv(GLenum(GL_RENDERBUFFER), GLenum(GL_RENDERBUFFER_WIDTH), &backingWidth) + glGetRenderbufferParameteriv(GLenum(GL_RENDERBUFFER), GLenum(GL_RENDERBUFFER_HEIGHT), &backingHeight) + + glFramebufferRenderbuffer(GLenum(GL_FRAMEBUFFER), GLenum(GL_COLOR_ATTACHMENT0), GLenum(GL_RENDERBUFFER), colorBufferHandle) + + if glCheckFramebufferStatus(GLenum(GL_FRAMEBUFFER)) != UInt32(GL_FRAMEBUFFER_COMPLETE) { + assertionFailure() + } + + self.frameBufferHandle = frameBufferHandle + self.colorBufferHandle = colorBufferHandle + + self.updateVisibleItems() + } + } + } + + private func updateVisibleItems() { + if let drawableSize = self.drawableSize { + var displayItems: [VisibleVideoItem] = [] + + let idealHeight: CGFloat = 93.0 + + var weights: [Int] = [] + var totalItemSize: CGFloat = 0.0 + for item in self.files { + let aspectRatio: CGFloat + if let dimensions = item.dimensions { + aspectRatio = dimensions.width / dimensions.height + } else { + aspectRatio = 1.0 + } + weights.append(Int(aspectRatio * 100)) + totalItemSize += aspectRatio * idealHeight + } + + let numberOfRows = max(Int(round(totalItemSize / drawableSize.width)), 1) + + let partition = linearPartitionForWeights(weights, numberOfPartitions:numberOfRows) + + var i = 0 + var offset = CGPoint(x: 0.0, y: 0.0) + var previousItemSize: CGFloat = 0.0 + var contentMaxValueInScrollDirection: CGFloat = 0.0 + let maxWidth = drawableSize.width + + let minimumInteritemSpacing: CGFloat = 1.0 + let minimumLineSpacing: CGFloat = 1.0 + + let viewportWidth: CGFloat = drawableSize.width + + let preferredRowSize = idealHeight + + var rowIndex = -1 + for row in partition { + rowIndex += 1 + + var summedRatios: CGFloat = 0.0 + + var j = i + var n = i + row.count + + while j < n { + let aspectRatio: CGFloat + if let dimensions = self.files[j].dimensions { + aspectRatio = dimensions.width / dimensions.height + } else { + aspectRatio = 1.0 + } + + summedRatios += aspectRatio + + j += 1 + } + + var rowSize = drawableSize.width - (CGFloat(row.count - 1) * minimumInteritemSpacing) + + if rowIndex == partition.count - 1 { + if row.count < 2 { + rowSize = floor(viewportWidth / 3.0) - (CGFloat(row.count - 1) * minimumInteritemSpacing) + } else if row.count < 3 { + rowSize = floor(viewportWidth * 2.0 / 3.0) - (CGFloat(row.count - 1) * minimumInteritemSpacing) + } + } + + j = i + n = i + row.count + + while j < n { + let aspectRatio: CGFloat + if let dimensions = self.files[j].dimensions { + aspectRatio = dimensions.width / dimensions.height + } else { + aspectRatio = 1.0 + } + let preferredAspectRatio = aspectRatio + + let actualSize = CGSize(width: round(rowSize / summedRatios * (preferredAspectRatio)), height: preferredRowSize) + + var frame = CGRect(x: offset.x, y: offset.y, width: actualSize.width, height: actualSize.height) + if frame.origin.x + frame.size.width >= maxWidth - 2.0 { + frame.size.width = max(1.0, maxWidth - frame.origin.x) + } + + displayItems.append(VisibleVideoItem(file: self.files[j], frame: frame)) + + offset.x += actualSize.width + minimumInteritemSpacing + previousItemSize = actualSize.height + contentMaxValueInScrollDirection = frame.maxY + + j += 1 + } + + if row.count > 0 { + offset = CGPoint(x: 0.0, y: offset.y + previousItemSize + minimumLineSpacing) + } + + i += row.count + } + let contentSize = CGSize(width: drawableSize.width, height: contentMaxValueInScrollDirection) + self.scrollView.contentSize = contentSize + + self.displayItems = displayItems + + self.validVisibleItemsOffset = nil + self.updateImmediatelyVisibleItems() + } + } + +} + +private func NH_LP_TABLE_LOOKUP(_ table: inout [Int], _ i: Int, _ j: Int, _ rowsize: Int) -> Int { + return table[i * rowsize + j] +} + +private func NH_LP_TABLE_LOOKUP_SET(_ table: inout [Int], _ i: Int, _ j: Int, _ rowsize: Int, _ value: Int) { + table[i * rowsize + j] = value +} + +private func linearPartitionTable(_ weights: [Int], numberOfPartitions: Int) -> [Int] { + let n = weights.count + let k = numberOfPartitions + + let tableSize = n * k; + var tmpTable = Array(repeatElement(0, count: tableSize)) + + let solutionSize = (n - 1) * (k - 1) + var solution = Array(repeatElement(0, count: solutionSize)) + + for i in 0 ..< n { + let offset = i != 0 ? NH_LP_TABLE_LOOKUP(&tmpTable, i - 1, 0, k) : 0 + NH_LP_TABLE_LOOKUP_SET(&tmpTable, i, 0, k, Int(weights[i]) + offset) + } + + for j in 0 ..< k { + NH_LP_TABLE_LOOKUP_SET(&tmpTable, 0, j, k, Int(weights[0])) + } + + for i in 1 ..< n { + for j in 1 ..< k { + var currentMin = 0 + var minX = Int.max + + for x in 0 ..< i { + let c1 = NH_LP_TABLE_LOOKUP(&tmpTable, x, j - 1, k) + let c2 = NH_LP_TABLE_LOOKUP(&tmpTable, i, 0, k) - NH_LP_TABLE_LOOKUP(&tmpTable, x, 0, k) + let cost = max(c1, c2) + + if x == 0 || cost < currentMin { + currentMin = cost; + minX = x + } + } + + NH_LP_TABLE_LOOKUP_SET(&tmpTable, i, j, k, currentMin) + NH_LP_TABLE_LOOKUP_SET(&solution, i - 1, j - 1, k - 1, minX) + } + } + + return solution +} + +private func linearPartitionForWeights(_ weights: [Int], numberOfPartitions: Int) -> [[Int]] { + var n = weights.count + var k = numberOfPartitions + + if k <= 0 { + return [] + } + + if k >= n { + var partition: [[Int]] = [] + for weight in weights { + partition.append([weight]) + } + return partition + } + + if n == 1 { + return [weights] + } + + var solution = linearPartitionTable(weights, numberOfPartitions: numberOfPartitions) + let solutionRowSize = numberOfPartitions - 1 + + k = k - 2; + n = n - 1; + + var answer: [[Int]] = [] + + while k >= 0 { + if n < 1 { + answer.insert([], at: 0) + } else { + var currentAnswer: [Int] = [] + + var i = NH_LP_TABLE_LOOKUP(&solution, n - 1, k, solutionRowSize) + 1 + let range = n + 1 + while i < range { + currentAnswer.append(weights[i]) + i += 1 + } + + answer.insert(currentAnswer, at: 0) + + n = NH_LP_TABLE_LOOKUP(&solution, n - 1, k, solutionRowSize) + } + + k = k - 1 + } + + var currentAnswer: [Int] = [] + var i = 0 + let range = n + 1 + while i < range { + currentAnswer.append(weights[i]) + i += 1 + } + + answer.insert(currentAnswer, at: 0) + + return answer +} diff --git a/TelegramUI/MultiplexedSoftwareVideoSourceManager.swift b/TelegramUI/MultiplexedSoftwareVideoSourceManager.swift new file mode 100644 index 0000000000..2c2f3f69c7 --- /dev/null +++ b/TelegramUI/MultiplexedSoftwareVideoSourceManager.swift @@ -0,0 +1,115 @@ +import Foundation +import Postbox +import TelegramCore +import SwiftSignalKit +import CoreMedia + +private final class RunningSoftwareVideoSource { + let fetchDisposable: Disposable + let fetchStatusDisposable: Disposable + + var source: SoftwareVideoSource? + var beginTime: Double? + var frame: MediaTrackFrame? + + init(fetchDisposable: Disposable, fetchStatusDisposable: Disposable) { + self.fetchDisposable = fetchDisposable + self.fetchStatusDisposable = fetchStatusDisposable + } + + deinit { + self.fetchDisposable.dispose() + self.fetchStatusDisposable.dispose() + } +} + +final class MultiplexedSoftwareVideoSourceManager { + private let queue: Queue + private let account: Account + private var videoSources: [MediaId: RunningSoftwareVideoSource] = [:] + private(set) var immediateVideoFrames: [MediaId: MediaTrackFrame] = [:] + + private var updatingAt: Double? + + var updateFrame: ((MediaId, MediaTrackFrame) -> Void)? + + init(queue: Queue, account: Account) { + self.queue = queue + self.account = account + } + + func updateVisibleItems(_ media: [TelegramMediaFile]) { + self.queue.async { + var dict: [MediaId: TelegramMediaFile] = [:] + for file in media { + dict[file.fileId] = file + } + + var removeIds: [MediaId] = [] + for id in self.videoSources.keys { + if dict[id] == nil { + removeIds.append(id) + } + } + + for id in removeIds { + self.videoSources.removeValue(forKey: id) + } + + for (id, file) in dict { + if self.videoSources[id] == nil { + self.videoSources[id] = RunningSoftwareVideoSource(fetchDisposable: (self.account.postbox.mediaBox.resourceData(file.resource) |> deliverOn(self.queue)).start(next: { [weak self] data in + if let strongSelf = self, let context = strongSelf.videoSources[id] { + if data.complete { + context.source = SoftwareVideoSource(path: data.path) + } + } + }), fetchStatusDisposable: self.account.postbox.mediaBox.fetchedResource(file.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .video)).start()) + } + } + } + } + + func update(at timestamp: Double) { + assert(Queue.mainQueue().isCurrent()) + let begin = self.updatingAt == nil + self.updatingAt = timestamp + if begin { + self.queue.async { + var immediateVideoFrames: [MediaId: MediaTrackFrame] = [:] + loop: for (id, source) in self.videoSources { + if let context = source.source { + if let beginTime = source.beginTime, let currentFrame = source.frame { + let framePosition = currentFrame.position.seconds + let frameDuration = currentFrame.duration.seconds + + if false && beginTime + framePosition + frameDuration > timestamp { + immediateVideoFrames[id] = currentFrame + continue loop + } + } + + /*if let frame = context.readFrame(maxPts: nil) { + if source.frame == nil || CMTimeCompare(source.frame!.position, frame.position) > 0 { + source.beginTime = timestamp + } + source.frame = frame + immediateVideoFrames[id] = frame + self.updateFrame?(id, frame) + }*/ + } + } + + Queue.mainQueue().async { + self.immediateVideoFrames = immediateVideoFrames + if let updatingAt = self.updatingAt, !updatingAt.isEqual(to: timestamp) { + self.updatingAt = nil + self.update(at: updatingAt) + } else { + self.updatingAt = nil + } + } + } + } + } +} diff --git a/TelegramUI/MultiplexedVideoNode.swift b/TelegramUI/MultiplexedVideoNode.swift new file mode 100644 index 0000000000..8aac487d66 --- /dev/null +++ b/TelegramUI/MultiplexedVideoNode.swift @@ -0,0 +1,503 @@ +import Foundation +import UIKit +import GLKit +import OpenGLES +import Display +import SwiftSignalKit +import AsyncDisplayKit +import Postbox +import TelegramCore + +private final class MultiplexedVideoTrackingNode: ASDisplayNode { + var inHierarchyUpdated: ((Bool) -> Void)? + + override func willEnterHierarchy() { + super.willEnterHierarchy() + + self.inHierarchyUpdated?(true) + } + + override func didExitHierarchy() { + super.didExitHierarchy() + + self.inHierarchyUpdated?(false) + } +} + +private final class VisibleVideoItem { + let file: TelegramMediaFile + let frame: CGRect + + init(file: TelegramMediaFile, frame: CGRect) { + self.file = file + self.frame = frame + } +} + +final class MultiplexedVideoNode: UIScrollView, UIScrollViewDelegate { + private let account: Account + private let trackingNode: MultiplexedVideoTrackingNode + + var files: [TelegramMediaFile] = [] { + didSet { + self.updateVisibleItems() + } + } + private var displayItems: [VisibleVideoItem] = [] + private var visibleThumbnailLayers: [MediaId: SoftwareVideoThumbnailLayer] = [:] + private var visibleLayers: [MediaId: (SoftwareVideoLayerFrameManager, SampleBufferLayer)] = [:] + + private var displayLink: CADisplayLink! + private var timeOffset = 0.0 + private var pauseTime = 0.0 + + private let timebase: CMTimebase + + var fileSelected: ((TelegramMediaFile) -> Void)? + var fileLongPressed: ((TelegramMediaFile) -> Void)? + var enableVideoNodes = false + + init(account: Account) { + self.account = account + self.trackingNode = MultiplexedVideoTrackingNode() + + var timebase: CMTimebase? + CMTimebaseCreateWithMasterClock(nil, CMClockGetHostTimeClock(), &timebase) + CMTimebaseSetRate(timebase!, 0.0) + self.timebase = timebase! + + super.init(frame: CGRect()) + + self.isOpaque = true + self.showsVerticalScrollIndicator = false + self.showsHorizontalScrollIndicator = false + self.alwaysBounceVertical = true + + self.addSubnode(self.trackingNode) + + class DisplayLinkProxy: NSObject { + weak var target: MultiplexedVideoNode? + init(target: MultiplexedVideoNode) { + self.target = target + } + + @objc func displayLinkEvent() { + self.target?.displayLinkEvent() + } + } + + self.displayLink = CADisplayLink(target: DisplayLinkProxy(target: self), selector: #selector(DisplayLinkProxy.displayLinkEvent)) + self.displayLink.add(to: RunLoop.main, forMode: RunLoopMode.commonModes) + if #available(iOS 10.0, *) { + self.displayLink.preferredFramesPerSecond = 25 + } else { + self.displayLink.frameInterval = 2 + } + self.displayLink.isPaused = true + + self.trackingNode.inHierarchyUpdated = { [weak self] value in + if let strongSelf = self { + if !value { + CMTimebaseSetRate(strongSelf.timebase, 0.0) + } else { + CMTimebaseSetRate(strongSelf.timebase, 1.0) + } + strongSelf.displayLink.isPaused = !value + if value && !strongSelf.enableVideoNodes { + strongSelf.enableVideoNodes = true + strongSelf.validVisibleItemsOffset = nil + strongSelf.updateImmediatelyVisibleItems() + } else if !value { + strongSelf.enableVideoNodes = false + } + } + } + + self.delegate = self + + let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + recognizer.tapActionAtPoint = { _ in + return .waitForSingleTap + } + self.addGestureRecognizer(recognizer) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.displayLink.invalidate() + self.displayLink.isPaused = true + } + + private func displayLinkEvent() { + let timestamp = CMTimebaseGetTime(self.timebase).seconds + for (_, (manager, _)) in self.visibleLayers { + manager.tick(timestamp: timestamp) + } + } + + private var validSize: CGSize? + override func layoutSubviews() { + super.layoutSubviews() + + if self.validSize == nil || !self.validSize!.equalTo(self.bounds.size) { + self.validSize = self.bounds.size + self.updateVisibleItems() + } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + self.updateImmediatelyVisibleItems() + } + + private var validVisibleItemsOffset: CGFloat? + private func updateImmediatelyVisibleItems(ensureFrames: Bool = false) { + let visibleBounds = self.bounds + let visibleThumbnailBounds = visibleBounds.insetBy(dx: 0.0, dy: -350.0) + + if let validVisibleItemsOffset = self.validVisibleItemsOffset, validVisibleItemsOffset.isEqual(to: visibleBounds.origin.y) { + return + } + self.validVisibleItemsOffset = visibleBounds.origin.y + let minVisibleY = visibleBounds.minY + let maxVisibleY = visibleBounds.maxY + + let minVisibleThumbnailY = visibleThumbnailBounds.minY + let maxVisibleThumbnailY = visibleThumbnailBounds.maxY + + var visibleThumbnailIds = Set() + var visibleIds = Set() + + for item in self.displayItems { + if item.frame.maxY < minVisibleThumbnailY { + continue; + } + if item.frame.minY > maxVisibleThumbnailY { + break; + } + + visibleThumbnailIds.insert(item.file.fileId) + + if let thumbnailLayer = self.visibleThumbnailLayers[item.file.fileId] { + if ensureFrames { + thumbnailLayer.frame = item.frame + } + } else { + let thumbnailLayer = SoftwareVideoThumbnailLayer(account: self.account, file: item.file) + thumbnailLayer.frame = item.frame + self.layer.addSublayer(thumbnailLayer) + self.visibleThumbnailLayers[item.file.fileId] = thumbnailLayer + } + + if item.frame.maxY < minVisibleY { + continue; + } + if item.frame.minY > maxVisibleY { + continue; + } + + visibleIds.insert(item.file.fileId) + + if let (_, layerHolder) = self.visibleLayers[item.file.fileId] { + if ensureFrames { + layerHolder.layer.frame = item.frame + } + } else { + let layerHolder = takeSampleBufferLayer() + layerHolder.layer.videoGravity = AVLayerVideoGravityResizeAspectFill + layerHolder.layer.frame = item.frame + self.layer.addSublayer(layerHolder.layer) + let manager = SoftwareVideoLayerFrameManager(account: self.account, resource: item.file.resource, layerHolder: layerHolder) + self.visibleLayers[item.file.fileId] = (manager, layerHolder) + self.visibleThumbnailLayers[item.file.fileId]?.ready = { [weak self] in + if let strongSelf = self { + strongSelf.visibleLayers[item.file.fileId]?.0.start() + } + } + } + } + + var removeIds: [MediaId] = [] + for id in self.visibleLayers.keys { + if !visibleIds.contains(id) { + removeIds.append(id) + } + } + + var removeThumbnailIds: [MediaId] = [] + for id in self.visibleThumbnailLayers.keys { + if !visibleThumbnailIds.contains(id) { + removeThumbnailIds.append(id) + } + } + + for id in removeIds { + let (_, layerHolder) = self.visibleLayers[id]! + layerHolder.layer.removeFromSuperlayer() + self.visibleLayers.removeValue(forKey: id) + } + + for id in removeThumbnailIds { + let thumbnailLayer = self.visibleThumbnailLayers[id]! + thumbnailLayer.removeFromSuperlayer() + self.visibleThumbnailLayers.removeValue(forKey: id) + } + } + + private func updateVisibleItems() { + let drawableSize = self.bounds.size + if !drawableSize.width.isZero { + var displayItems: [VisibleVideoItem] = [] + + let idealHeight: CGFloat = 93.0 + + var weights: [Int] = [] + var totalItemSize: CGFloat = 0.0 + for item in self.files { + let aspectRatio: CGFloat + if let dimensions = item.dimensions { + aspectRatio = dimensions.width / dimensions.height + } else { + aspectRatio = 1.0 + } + weights.append(Int(aspectRatio * 100)) + totalItemSize += aspectRatio * idealHeight + } + + let numberOfRows = max(Int(round(totalItemSize / drawableSize.width)), 1) + + let partition = linearPartitionForWeights(weights, numberOfPartitions:numberOfRows) + + var i = 0 + var offset = CGPoint(x: 0.0, y: 0.0) + var previousItemSize: CGFloat = 0.0 + var contentMaxValueInScrollDirection: CGFloat = 0.0 + let maxWidth = drawableSize.width + + let minimumInteritemSpacing: CGFloat = 1.0 + let minimumLineSpacing: CGFloat = 1.0 + + let viewportWidth: CGFloat = drawableSize.width + + let preferredRowSize = idealHeight + + var rowIndex = -1 + for row in partition { + rowIndex += 1 + + var summedRatios: CGFloat = 0.0 + + var j = i + var n = i + row.count + + while j < n { + let aspectRatio: CGFloat + if let dimensions = self.files[j].dimensions { + aspectRatio = dimensions.width / dimensions.height + } else { + aspectRatio = 1.0 + } + + summedRatios += aspectRatio + + j += 1 + } + + var rowSize = drawableSize.width - (CGFloat(row.count - 1) * minimumInteritemSpacing) + + if rowIndex == partition.count - 1 { + if row.count < 2 { + rowSize = floor(viewportWidth / 3.0) - (CGFloat(row.count - 1) * minimumInteritemSpacing) + } else if row.count < 3 { + rowSize = floor(viewportWidth * 2.0 / 3.0) - (CGFloat(row.count - 1) * minimumInteritemSpacing) + } + } + + j = i + n = i + row.count + + while j < n { + let aspectRatio: CGFloat + if let dimensions = self.files[j].dimensions { + aspectRatio = dimensions.width / dimensions.height + } else { + aspectRatio = 1.0 + } + let preferredAspectRatio = aspectRatio + + let actualSize = CGSize(width: round(rowSize / summedRatios * (preferredAspectRatio)), height: preferredRowSize) + + var frame = CGRect(x: offset.x, y: offset.y, width: actualSize.width, height: actualSize.height) + if frame.origin.x + frame.size.width >= maxWidth - 2.0 { + frame.size.width = max(1.0, maxWidth - frame.origin.x) + } + + displayItems.append(VisibleVideoItem(file: self.files[j], frame: frame)) + + offset.x += actualSize.width + minimumInteritemSpacing + previousItemSize = actualSize.height + contentMaxValueInScrollDirection = frame.maxY + + j += 1 + } + + if row.count > 0 { + offset = CGPoint(x: 0.0, y: offset.y + previousItemSize + minimumLineSpacing) + } + + i += row.count + } + let contentSize = CGSize(width: drawableSize.width, height: contentMaxValueInScrollDirection) + self.contentSize = contentSize + + self.displayItems = displayItems + + self.validVisibleItemsOffset = nil + self.updateImmediatelyVisibleItems(ensureFrames: true) + } + } + + @objc func tapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { + if case .ended = recognizer.state { + if let (gesture, point) = recognizer.lastRecognizedGestureAndLocation { + for item in self.displayItems { + if item.frame.contains(point) { + switch gesture { + case .tap: + self.fileSelected?(item.file) + case .longTap: + self.fileLongPressed?(item.file) + default: + break + } + break + } + } + } + } + } + + func frameForItem(_ id: MediaId) -> CGRect? { + for item in self.displayItems { + if item.file.fileId == id { + return item.frame + } + } + return nil + } +} + +private func NH_LP_TABLE_LOOKUP(_ table: inout [Int], _ i: Int, _ j: Int, _ rowsize: Int) -> Int { + return table[i * rowsize + j] +} + +private func NH_LP_TABLE_LOOKUP_SET(_ table: inout [Int], _ i: Int, _ j: Int, _ rowsize: Int, _ value: Int) { + table[i * rowsize + j] = value +} + +private func linearPartitionTable(_ weights: [Int], numberOfPartitions: Int) -> [Int] { + let n = weights.count + let k = numberOfPartitions + + let tableSize = n * k; + var tmpTable = Array(repeatElement(0, count: tableSize)) + + let solutionSize = (n - 1) * (k - 1) + var solution = Array(repeatElement(0, count: solutionSize)) + + for i in 0 ..< n { + let offset = i != 0 ? NH_LP_TABLE_LOOKUP(&tmpTable, i - 1, 0, k) : 0 + NH_LP_TABLE_LOOKUP_SET(&tmpTable, i, 0, k, Int(weights[i]) + offset) + } + + for j in 0 ..< k { + NH_LP_TABLE_LOOKUP_SET(&tmpTable, 0, j, k, Int(weights[0])) + } + + for i in 1 ..< n { + for j in 1 ..< k { + var currentMin = 0 + var minX = Int.max + + for x in 0 ..< i { + let c1 = NH_LP_TABLE_LOOKUP(&tmpTable, x, j - 1, k) + let c2 = NH_LP_TABLE_LOOKUP(&tmpTable, i, 0, k) - NH_LP_TABLE_LOOKUP(&tmpTable, x, 0, k) + let cost = max(c1, c2) + + if x == 0 || cost < currentMin { + currentMin = cost; + minX = x + } + } + + NH_LP_TABLE_LOOKUP_SET(&tmpTable, i, j, k, currentMin) + NH_LP_TABLE_LOOKUP_SET(&solution, i - 1, j - 1, k - 1, minX) + } + } + + return solution +} + +private func linearPartitionForWeights(_ weights: [Int], numberOfPartitions: Int) -> [[Int]] { + var n = weights.count + var k = numberOfPartitions + + if k <= 0 { + return [] + } + + if k >= n { + var partition: [[Int]] = [] + for weight in weights { + partition.append([weight]) + } + return partition + } + + if n == 1 { + return [weights] + } + + var solution = linearPartitionTable(weights, numberOfPartitions: numberOfPartitions) + let solutionRowSize = numberOfPartitions - 1 + + k = k - 2; + n = n - 1; + + var answer: [[Int]] = [] + + while k >= 0 { + if n < 1 { + answer.insert([], at: 0) + } else { + var currentAnswer: [Int] = [] + + var i = NH_LP_TABLE_LOOKUP(&solution, n - 1, k, solutionRowSize) + 1 + let range = n + 1 + while i < range { + currentAnswer.append(weights[i]) + i += 1 + } + + answer.insert(currentAnswer, at: 0) + + n = NH_LP_TABLE_LOOKUP(&solution, n - 1, k, solutionRowSize) + } + + k = k - 1 + } + + var currentAnswer: [Int] = [] + var i = 0 + let range = n + 1 + while i < range { + currentAnswer.append(weights[i]) + i += 1 + } + + answer.insert(currentAnswer, at: 0) + + return answer +} diff --git a/TelegramUI/NavigateToChatController.swift b/TelegramUI/NavigateToChatController.swift index 0a8b099bf6..060207deb5 100644 --- a/TelegramUI/NavigateToChatController.swift +++ b/TelegramUI/NavigateToChatController.swift @@ -3,11 +3,11 @@ import Display import TelegramCore import Postbox -func navigateToChatController(navigationController: NavigationController, account: Account, peerId: PeerId) { +public 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) + let _ = navigationController.popToViewController(controller, animated: true) found = true break } diff --git a/TelegramUI/NetworkStatusTitleView.swift b/TelegramUI/NetworkStatusTitleView.swift index ed620d03f1..bd2e94dea7 100644 --- a/TelegramUI/NetworkStatusTitleView.swift +++ b/TelegramUI/NetworkStatusTitleView.swift @@ -14,7 +14,9 @@ struct NetworkStatusTitle: Equatable { final class NetworkStatusTitleView: UIView { private let titleNode: ASTextNode + private let lockView: ChatListTitleLockView private let activityIndicator: UIActivityIndicatorView + private let buttonView: HighlightTrackingButton var title: NetworkStatusTitle = NetworkStatusTitle(text: "", activity: false) { didSet { @@ -34,19 +36,52 @@ final class NetworkStatusTitleView: UIView { } } + var toggleIsLocked: (() -> Void)? + + private var isPasscodeSet = false + private var isManuallyLocked = false + override init(frame: CGRect) { self.titleNode = ASTextNode() self.titleNode.displaysAsynchronously = false self.titleNode.maximumNumberOfLines = 1 self.titleNode.truncationMode = .byTruncatingTail self.titleNode.isOpaque = false + self.titleNode.isUserInteractionEnabled = false self.activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray) + self.activityIndicator.isHidden = true + + self.lockView = ChatListTitleLockView(frame: CGRect(origin: CGPoint(), size: CGSize(width: 2.0, height: 2.0))) + self.lockView.isHidden = true + self.lockView.isUserInteractionEnabled = false + + self.buttonView = HighlightTrackingButton() super.init(frame: frame) + self.addSubview(self.buttonView) self.addSubnode(self.titleNode) + self.addSubview(self.lockView) self.addSubview(self.activityIndicator) + + self.buttonView.highligthedChanged = { [weak self] highlighted in + if let strongSelf = self { + if highlighted && strongSelf.activityIndicator.isHidden { + strongSelf.titleNode.layer.removeAnimation(forKey: "opacity") + strongSelf.lockView.layer.removeAnimation(forKey: "opacity") + strongSelf.titleNode.alpha = 0.4 + strongSelf.lockView.alpha = 0.4 + } else if !strongSelf.titleNode.alpha.isEqual(to: 1.0) { + strongSelf.titleNode.alpha = 1.0 + strongSelf.lockView.alpha = 1.0 + strongSelf.titleNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + strongSelf.lockView.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + } + } + } + + self.buttonView.addTarget(self, action: #selector(self.buttonPressed), for: .touchUpInside) } required init?(coder aDecoder: NSCoder) { @@ -57,6 +92,9 @@ final class NetworkStatusTitleView: UIView { super.layoutSubviews() let size = self.bounds.size + + self.buttonView.frame = CGRect(origin: CGPoint(), size: size) + var indicatorPadding: CGFloat = 0.0 let indicatorSize = self.activityIndicator.bounds.size @@ -70,8 +108,33 @@ final class NetworkStatusTitleView: UIView { let titleFrame = CGRect(origin: CGPoint(x: indicatorPadding + floor((size.width - titleSize.width - indicatorPadding) / 2.0), y: floor((size.height - combinedHeight) / 2.0)), size: titleSize) self.titleNode.frame = titleFrame + self.lockView.frame = CGRect(x: titleFrame.maxX + 6.0, y: titleFrame.minY + 4.0, width: 2.0, height: 2.0) + if !self.activityIndicator.isHidden { self.activityIndicator.frame = CGRect(origin: CGPoint(x: titleFrame.minX - indicatorSize.width - 6.0, y: titleFrame.minY + 1.0), size: indicatorSize) } } + + func updatePasscode(isPasscodeSet: Bool, isManuallyLocked: Bool) { + if self.isPasscodeSet == isPasscodeSet && self.isManuallyLocked == isManuallyLocked { + return + } + + self.isPasscodeSet = isPasscodeSet + self.isManuallyLocked = isManuallyLocked + + if isPasscodeSet { + self.buttonView.isHidden = false + self.lockView.isHidden = false + self.lockView.setIsLocked(isManuallyLocked, animated: !self.bounds.size.width.isZero) + } else { + self.buttonView.isHidden = true + self.lockView.isHidden = true + self.lockView.setIsLocked(false, animated: false) + } + } + + @objc func buttonPressed() { + self.toggleIsLocked?() + } } diff --git a/TelegramUI/NetworkUsageStatsController.swift b/TelegramUI/NetworkUsageStatsController.swift new file mode 100644 index 0000000000..f92c5069f2 --- /dev/null +++ b/TelegramUI/NetworkUsageStatsController.swift @@ -0,0 +1,429 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private enum NetworkUsageControllerSection { + case cellular + case wifi +} + +private final class NetworkUsageStatsControllerArguments { + let resetStatistics: (NetworkUsageControllerSection) -> Void + + init(resetStatistics: @escaping (NetworkUsageControllerSection) -> Void) { + self.resetStatistics = resetStatistics + } +} + +private enum NetworkUsageStatsSection: Int32 { + case messages + case image + case video + case audio + case file + case call + case total + case reset +} + +private enum NetworkUsageStatsEntry: ItemListNodeEntry { + case messagesHeader(String) + case messagesSent(String, String) + case messagesReceived(String, String) + + case imageHeader(String) + case imageSent(String, String) + case imageReceived(String, String) + + case videoHeader(String) + case videoSent(String, String) + case videoReceived(String, String) + + case audioHeader(String) + case audioSent(String, String) + case audioReceived(String, String) + + case fileHeader(String) + case fileSent(String, String) + case fileReceived(String, String) + + case callHeader(String) + case callSent(String, String) + case callReceived(String, String) + + case reset(NetworkUsageControllerSection, String) + case resetTimestamp(String) + + var section: ItemListSectionId { + switch self { + case .messagesHeader, .messagesSent, .messagesReceived: + return NetworkUsageStatsSection.messages.rawValue + case .imageHeader, .imageSent, .imageReceived: + return NetworkUsageStatsSection.image.rawValue + case .videoHeader, .videoSent, .videoReceived: + return NetworkUsageStatsSection.video.rawValue + case .audioHeader, .audioSent, .audioReceived: + return NetworkUsageStatsSection.audio.rawValue + case .fileHeader, .fileSent, .fileReceived: + return NetworkUsageStatsSection.file.rawValue + case .callHeader, .callSent, .callReceived: + return NetworkUsageStatsSection.call.rawValue + case .reset, .resetTimestamp: + return NetworkUsageStatsSection.reset.rawValue + } + } + + var stableId: Int32 { + switch self { + case .messagesHeader: + return 0 + case .messagesSent: + return 1 + case .messagesReceived: + return 2 + case .imageHeader: + return 3 + case .imageSent: + return 4 + case .imageReceived: + return 5 + case .videoHeader: + return 6 + case .videoSent: + return 7 + case .videoReceived: + return 8 + case .audioHeader: + return 9 + case .audioSent: + return 10 + case .audioReceived: + return 11 + case .fileHeader: + return 12 + case .fileSent: + return 13 + case .fileReceived: + return 14 + case .callHeader: + return 15 + case .callSent: + return 16 + case .callReceived: + return 17 + case .reset: + return 18 + case .resetTimestamp: + return 19 + } + } + + static func ==(lhs: NetworkUsageStatsEntry, rhs: NetworkUsageStatsEntry) -> Bool { + switch lhs { + case let .messagesHeader(text): + if case .messagesHeader(text) = rhs { + return true + } else { + return false + } + case let .messagesSent(text, value): + if case .messagesSent(text, value) = rhs { + return true + } else { + return false + } + case let .messagesReceived(text, value): + if case .messagesReceived(text, value) = rhs { + return true + } else { + return false + } + case let .imageHeader(text): + if case .imageHeader(text) = rhs { + return true + } else { + return false + } + case let .imageSent(text, value): + if case .imageSent(text, value) = rhs { + return true + } else { + return false + } + case let .imageReceived(text, value): + if case .imageReceived(text, value) = rhs { + return true + } else { + return false + } + case let .videoHeader(text): + if case .videoHeader(text) = rhs { + return true + } else { + return false + } + case let .videoSent(text, value): + if case .videoSent(text, value) = rhs { + return true + } else { + return false + } + case let .videoReceived(text, value): + if case .videoReceived(text, value) = rhs { + return true + } else { + return false + } + case let .audioHeader(text): + if case .audioHeader(text) = rhs { + return true + } else { + return false + } + case let .audioSent(text, value): + if case .audioSent(text, value) = rhs { + return true + } else { + return false + } + case let .audioReceived(text, value): + if case .audioReceived(text, value) = rhs { + return true + } else { + return false + } + case let .fileHeader(text): + if case .fileHeader(text) = rhs { + return true + } else { + return false + } + case let .fileSent(text, value): + if case .fileSent(text, value) = rhs { + return true + } else { + return false + } + case let .fileReceived(text, value): + if case .fileReceived(text, value) = rhs { + return true + } else { + return false + } + case let .callHeader(text): + if case .callHeader(text) = rhs { + return true + } else { + return false + } + case let .callSent(text, value): + if case .callSent(text, value) = rhs { + return true + } else { + return false + } + case let .callReceived(text, value): + if case .callReceived(text, value) = rhs { + return true + } else { + return false + } + case let .reset(section, text): + if case .reset(section, text) = rhs { + return true + } else { + return false + } + case let .resetTimestamp(text): + if case .resetTimestamp(text) = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: NetworkUsageStatsEntry, rhs: NetworkUsageStatsEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(_ arguments: NetworkUsageStatsControllerArguments) -> ListViewItem { + switch self { + case let .messagesHeader(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + case let .messagesSent(text, value): + return ItemListDisclosureItem(title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none , action: nil) + case let .messagesReceived(text, value): + return ItemListDisclosureItem(title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none , action: nil) + case let .imageHeader(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + case let .imageSent(text, value): + return ItemListDisclosureItem(title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none , action: nil) + case let .imageReceived(text, value): + return ItemListDisclosureItem(title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none , action: nil) + case let .videoHeader(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + case let .videoSent(text, value): + return ItemListDisclosureItem(title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none , action: nil) + case let .videoReceived(text, value): + return ItemListDisclosureItem(title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none , action: nil) + case let .audioHeader(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + case let .audioSent(text, value): + return ItemListDisclosureItem(title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none , action: nil) + case let .audioReceived(text, value): + return ItemListDisclosureItem(title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none , action: nil) + case let .fileHeader(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + case let .fileSent(text, value): + return ItemListDisclosureItem(title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none , action: nil) + case let .fileReceived(text, value): + return ItemListDisclosureItem(title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none , action: nil) + case let .callHeader(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + case let .callSent(text, value): + return ItemListDisclosureItem(title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none , action: nil) + case let .callReceived(text, value): + return ItemListDisclosureItem(title: text, label: value, sectionId: self.section, style: .blocks, disclosureStyle: .none , action: nil) + case let .reset(section, text): + return ItemListActionItem(title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { + arguments.resetStatistics(section) + }) + case let .resetTimestamp(text): + return ItemListTextItem(text: .plain(text), sectionId: self.section) + } + } +} + +private func networkUsageStatsControllerEntries(section: NetworkUsageControllerSection, stats: NetworkUsageStats) -> [NetworkUsageStatsEntry] { + var entries: [NetworkUsageStatsEntry] = [] + + switch section { + case .cellular: + entries.append(.messagesHeader("MESSAGES")) + entries.append(.messagesSent("Bytes Sent", dataSizeString(Int(stats.generic.cellular.outgoing)))) + entries.append(.messagesReceived("Bytes Received", dataSizeString(Int(stats.generic.cellular.incoming)))) + + entries.append(.imageHeader("PHOTOS")) + entries.append(.imageSent("Bytes Sent", dataSizeString(Int(stats.image.cellular.outgoing)))) + entries.append(.imageReceived("Bytes Received", dataSizeString(Int(stats.image.cellular.incoming)))) + + entries.append(.videoHeader("VIDEOS")) + entries.append(.videoSent("Bytes Sent", dataSizeString(Int(stats.video.cellular.outgoing)))) + entries.append(.videoReceived("Bytes Received", dataSizeString(Int(stats.video.cellular.incoming)))) + + entries.append(.audioHeader("AUDIO")) + entries.append(.audioSent("Bytes Sent", dataSizeString(Int(stats.audio.cellular.outgoing)))) + entries.append(.audioReceived("Bytes Received", dataSizeString(Int(stats.audio.cellular.incoming)))) + + entries.append(.fileHeader("DOCUMENTS")) + entries.append(.fileSent("Bytes Sent", dataSizeString(Int(stats.file.cellular.outgoing)))) + entries.append(.fileReceived("Bytes Received", dataSizeString(Int(stats.file.cellular.incoming)))) + + entries.append(.callHeader("CALLS")) + entries.append(.callSent("Bytes Sent", dataSizeString(0))) + entries.append(.callReceived("Bytes Received", dataSizeString(0))) + + entries.append(.reset(section, "Reset Statistics")) + + if stats.resetCellularTimestamp != 0 { + let formatter = DateFormatter() + formatter.dateFormat = "E, d MMM yyyy HH:mm" + let dateStringPlain = formatter.string(from: Date(timeIntervalSince1970: Double(stats.resetCellularTimestamp))) + + entries.append(.resetTimestamp("Cellular usage since \(dateStringPlain)")) + } + case .wifi: + entries.append(.messagesHeader("MESSAGES")) + entries.append(.messagesSent("Bytes Sent", dataSizeString(Int(stats.generic.wifi.outgoing)))) + entries.append(.messagesReceived("Bytes Received", dataSizeString(Int(stats.generic.wifi.incoming)))) + + entries.append(.imageHeader("PHOTOS")) + entries.append(.imageSent("Bytes Sent", dataSizeString(Int(stats.image.wifi.outgoing)))) + entries.append(.imageReceived("Bytes Received", dataSizeString(Int(stats.image.wifi.incoming)))) + + entries.append(.videoHeader("VIDEOS")) + entries.append(.videoSent("Bytes Sent", dataSizeString(Int(stats.video.wifi.outgoing)))) + entries.append(.videoReceived("Bytes Received", dataSizeString(Int(stats.video.wifi.incoming)))) + + entries.append(.audioHeader("AUDIO")) + entries.append(.audioSent("Bytes Sent", dataSizeString(Int(stats.audio.wifi.outgoing)))) + entries.append(.audioReceived("Bytes Received", dataSizeString(Int(stats.audio.wifi.incoming)))) + + entries.append(.fileHeader("DOCUMENTS")) + entries.append(.fileSent("Bytes Sent", dataSizeString(Int(stats.file.wifi.outgoing)))) + entries.append(.fileReceived("Bytes Received", dataSizeString(Int(stats.file.wifi.incoming)))) + + entries.append(.callHeader("CALLS")) + entries.append(.callSent("Bytes Sent", dataSizeString(0))) + entries.append(.callReceived("Bytes Received", dataSizeString(0))) + + entries.append(.reset(section, "Reset Statistics")) + if stats.resetWifiTimestamp != 0 { + let formatter = DateFormatter() + formatter.dateFormat = "E, d MMM yyyy HH:mm" + let dateStringPlain = formatter.string(from: Date(timeIntervalSince1970: Double(stats.resetWifiTimestamp))) + + entries.append(.resetTimestamp("Wifi usage since \(dateStringPlain)")) + } + } + + return entries +} + +func networkUsageStatsController(account: Account) -> ViewController { + let section = ValuePromise(.cellular) + let stats = Promise() + stats.set(accountNetworkUsageStats(account: account, reset: [])) + + var presentControllerImpl: ((ViewController) -> Void)? + + let arguments = NetworkUsageStatsControllerArguments(resetStatistics: { [weak stats] section in + let controller = ActionSheetController() + let dismissAction: () -> Void = { [weak controller] in + controller?.dismissAnimated() + } + controller.setItemGroups([ + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "Reset Statistics", color: .destructive, action: { + dismissAction() + + let reset: ResetNetworkUsageStats + switch section { + case .wifi: + reset = .wifi + case .cellular: + reset = .cellular + } + stats?.set(accountNetworkUsageStats(account: account, reset: reset)) + }), + ]), + ActionSheetItemGroup(items: [ActionSheetButtonItem(title: "Cancel", action: { dismissAction() })]) + ]) + presentControllerImpl?(controller) + }) + + let signal = combineLatest(section.get(), stats.get()) |> deliverOnMainQueue + |> map { section, stats -> (ItemListControllerState, (ItemListNodeState, NetworkUsageStatsEntry.ItemGenerationArguments)) in + + let controllerState = ItemListControllerState(title: .sectionControl(["Cellular", "Wifi"], 0), leftNavigationButton: nil, rightNavigationButton: nil, animateChanges: false) + let listState = ItemListNodeState(entries: networkUsageStatsControllerEntries(section: section, stats: stats), style: .blocks, emptyStateItem: nil, animateChanges: false) + + return (controllerState, (listState, arguments)) + } + + let controller = ItemListController(signal) + controller.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + + controller.titleControlValueChanged = { [weak section] index in + section?.set(index == 0 ? .cellular : .wifi) + } + + presentControllerImpl = { [weak controller] c in + controller?.present(c, in: .window, with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + } + + return controller +} diff --git a/TelegramUI/NotificationContainerController.swift b/TelegramUI/NotificationContainerController.swift new file mode 100644 index 0000000000..c53d1b9fc3 --- /dev/null +++ b/TelegramUI/NotificationContainerController.swift @@ -0,0 +1,50 @@ +import Foundation +import Display +import AsyncDisplayKit + +public final class NotificationContainerController: ViewController { + private var controllerNode: NotificationContainerControllerNode { + return self.displayNode as! NotificationContainerControllerNode + } + + public init() { + super.init(navigationBar: NavigationBar()) + + self.statusBar.statusBarStyle = .Ignore + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func loadView() { + super.loadView() + + self.navigationBar.removeFromSupernode() + } + + override public func loadDisplayNode() { + self.displayNode = NotificationContainerControllerNode() + self.displayNodeDidLoad() + + self.controllerNode.displayingItemsUpdated = { [weak self] value in + if let strongSelf = self { + strongSelf.statusBar.statusBarStyle = value ? .Hide : .Ignore + } + } + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.controllerNode.containerLayoutUpdated(layout, transition: transition) + } + + public func removeItemsWithGroupingKey(_ key: AnyHashable) { + self.controllerNode.removeItemsWithGroupingKey(key) + } + + public func enqueue(_ item: NotificationItem) { + self.controllerNode.enqueue(item) + } +} diff --git a/TelegramUI/NotificationContainerControllerNode.swift b/TelegramUI/NotificationContainerControllerNode.swift new file mode 100644 index 0000000000..da259af174 --- /dev/null +++ b/TelegramUI/NotificationContainerControllerNode.swift @@ -0,0 +1,128 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit + +private final class NotificationContainerControllerNodeView: UITracingLayerView { + var hitTestImpl: ((CGPoint, UIEvent?) -> UIView?)? + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return self.hitTestImpl?(point, event) + } +} + +final class NotificationContainerControllerNode: ASDisplayNode { + private var validLayout: ContainerViewLayout? + private var topItemAndNode: (NotificationItem, NotificationItemContainerNode)? + + var displayingItemsUpdated: ((Bool) -> Void)? + + private var timeoutTimer: SwiftSignalKit.Timer? + + override init() { + super.init(viewBlock: { + return NotificationContainerControllerNodeView() + }, didLoad: nil) + + self.backgroundColor = nil + self.isOpaque = false + } + + override func didLoad() { + super.didLoad() + + (self.view as! NotificationContainerControllerNodeView).hitTestImpl = { [weak self] point, event in + return self?.hitTest(point, with: event) + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if let (_, topItemNode) = self.topItemAndNode { + return topItemNode.hitTest(point, with: event) + } + return nil + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + self.validLayout = layout + + if let (_, topItemNode) = self.topItemAndNode { + topItemNode.updateLayout(layout: layout, transition: transition) + } + } + + func removeItemsWithGroupingKey(_ key: AnyHashable) { + if let (item, topItemNode) = self.topItemAndNode { + if item.groupingKey == key { + self.topItemAndNode = nil + topItemNode.animateOut(completion: { [weak self, weak topItemNode] in + topItemNode?.removeFromSupernode() + + if let strongSelf = self, strongSelf.topItemAndNode == nil { + strongSelf.displayingItemsUpdated?(false) + } + }) + } + } + } + + func enqueue(_ item: NotificationItem) { + var updatedDisplayingItems = false + if let (_, topItemNode) = self.topItemAndNode { + topItemNode.animateOut(completion: { [weak self, weak topItemNode] in + topItemNode?.removeFromSupernode() + + if let strongSelf = self, strongSelf.topItemAndNode == nil { + strongSelf.displayingItemsUpdated?(false) + } + }) + } else { + updatedDisplayingItems = true + } + + let itemNode = item.node() + let containerNode = NotificationItemContainerNode() + containerNode.item = item + containerNode.contentNode = itemNode + containerNode.dismissed = { [weak self] item in + if let strongSelf = self { + if let (topItem, topItemNode) = strongSelf.topItemAndNode, topItem.groupingKey != nil && topItem.groupingKey == item.groupingKey { + topItemNode.removeFromSupernode() + + if let strongSelf = self, strongSelf.topItemAndNode == nil { + strongSelf.displayingItemsUpdated?(false) + } + } + } + } + self.topItemAndNode = (item, containerNode) + self.addSubnode(containerNode) + + if let validLayout = self.validLayout { + containerNode.updateLayout(layout: validLayout, transition: .immediate) + containerNode.animateIn() + } + + if updatedDisplayingItems { + self.displayingItemsUpdated?(true) + } + + self.timeoutTimer?.invalidate() + let timeoutTimer = SwiftSignalKit.Timer(timeout: 5.0, repeat: false, completion: { [weak self] in + if let strongSelf = self { + if let (_, topItemNode) = strongSelf.topItemAndNode { + strongSelf.topItemAndNode = nil + topItemNode.animateOut(completion: { [weak topItemNode] in + topItemNode?.removeFromSupernode() + + if let strongSelf = self, strongSelf.topItemAndNode == nil { + strongSelf.displayingItemsUpdated?(false) + } + }) + } + } + }, queue: Queue.mainQueue()) + self.timeoutTimer = timeoutTimer + timeoutTimer.start() + } +} diff --git a/TelegramUI/NotificationItem.swift b/TelegramUI/NotificationItem.swift new file mode 100644 index 0000000000..4aab094f47 --- /dev/null +++ b/TelegramUI/NotificationItem.swift @@ -0,0 +1,16 @@ +import Foundation +import AsyncDisplayKit +import Display + +public protocol NotificationItem { + var groupingKey: AnyHashable? { get } + + func node() -> NotificationItemNode + func tapped() +} + +public class NotificationItemNode: ASDisplayNode { + func updateLayout(width: CGFloat, transition: ContainedViewLayoutTransition) -> CGFloat { + return 32.0 + } +} diff --git a/TelegramUI/NotificationItemContainerNode.swift b/TelegramUI/NotificationItemContainerNode.swift new file mode 100644 index 0000000000..910c6abdb7 --- /dev/null +++ b/TelegramUI/NotificationItemContainerNode.swift @@ -0,0 +1,122 @@ +import Foundation +import AsyncDisplayKit +import Display + +private let backgroundImageWithShadow = generateImage(CGSize(width: 30.0 + 8.0 * 2.0, height: 30.0 + 8.0 + 20.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setShadow(offset: CGSize(width: 0.0, height: -4.0), blur: 40.0, color: UIColor(white: 0.0, alpha: 0.3).cgColor) + context.setFillColor(UIColor.white.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: 8.0, y: 8.0), size: CGSize(width: 30.0, height: 30.0))) +})?.stretchableImage(withLeftCapWidth: 8 + 15, topCapHeight: 8 + 15) + +final class NotificationItemContainerNode: ASDisplayNode { + private let backgroundNode: ASImageNode + + private var validLayout: ContainerViewLayout? + + var item: NotificationItem? + + var contentNode: NotificationItemNode? { + didSet { + if self.contentNode !== oldValue { + oldValue?.removeFromSupernode() + } + + if let contentNode = self.contentNode { + self.addSubnode(contentNode) + + if let validLayout = self.validLayout { + self.updateLayout(layout: validLayout, transition: .immediate) + } + } + } + } + + var dismissed: ((NotificationItem) -> Void)? + + override init() { + self.backgroundNode = ASImageNode() + self.backgroundNode.displayWithoutProcessing = true + self.backgroundNode.displaysAsynchronously = false + self.backgroundNode.image = backgroundImageWithShadow + + super.init() + + self.addSubnode(self.backgroundNode) + } + + override func didLoad() { + super.didLoad() + + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) + panRecognizer.delaysTouchesBegan = false + panRecognizer.cancelsTouchesInView = false + self.view.addGestureRecognizer(panRecognizer) + } + + func animateIn() { + self.layer.animatePosition(from: CGPoint(x: 0.0, y: -100.0), to: CGPoint(), duration: 0.4, additive: true) + } + + func animateOut(completion: @escaping () -> Void) { + self.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: -100.0), duration: 0.4, removeOnCompletion: false, additive: true, completion: { _ in + completion() + }) + } + + func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + self.validLayout = layout + + if let contentNode = self.contentNode { + let contentInsets = UIEdgeInsets(top: 8.0, left: 8.0, bottom: 8.0, right: 8.0) + let contentWidth = layout.size.width - contentInsets.left - contentInsets.right + let contentHeight = contentNode.updateLayout(width: contentWidth, transition: transition) + + transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: layout.size.width, height: 8.0 + contentHeight + 20.0))) + + transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(x: contentInsets.left, y: contentInsets.top), size: CGSize(width: contentWidth, height: contentHeight))) + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if let contentNode = self.contentNode, contentNode.frame.contains(point) { + return self.view + } + return nil + } + + @objc func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + if let item = self.item { + item.tapped() + } + } + } + + @objc func panGesture(_ recognizer: UIPanGestureRecognizer) { + switch recognizer.state { + case .began: + break + case .changed: + let translation = recognizer.translation(in: self.view) + var bounds = self.bounds + bounds.origin.y = max(0.0, -translation.y) + self.bounds = bounds + case .ended: + self.animateOut(completion: { [weak self] in + if let strongSelf = self, let item = strongSelf.item { + strongSelf.dismissed?(item) + } + }) + case .cancelled: + let previousBounds = self.bounds + var bounds = self.bounds + bounds.origin.y = 0.0 + self.bounds = bounds + self.layer.animateBounds(from: previousBounds, to: self.bounds, duration: 0.3, timingFunction: kCAMediaTimingFunctionEaseInEaseOut) + default: + break + } + } +} diff --git a/TelegramUI/NotificationSoundSelection.swift b/TelegramUI/NotificationSoundSelection.swift index 27cd16c2db..9da77f0b1e 100644 --- a/TelegramUI/NotificationSoundSelection.swift +++ b/TelegramUI/NotificationSoundSelection.swift @@ -162,7 +162,7 @@ public func notificationSoundSelectionController(account: Account, isModal: Bool let signal = statePromise.get() |> map { state -> (ItemListControllerState, (ItemListNodeState, NotificationSoundSelectionEntry.ItemGenerationArguments)) in - let controllerState = ItemListControllerState(title: "Text Tone", leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton) + let controllerState = ItemListControllerState(title: .text("Text Tone"), leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton) let listState = ItemListNodeState(entries: notificationsAndSoundsEntries(state: state), style: .blocks) return (controllerState, (listState, arguments)) diff --git a/TelegramUI/NotificationsAndSounds.swift b/TelegramUI/NotificationsAndSounds.swift index b8fd281112..81863ce63f 100644 --- a/TelegramUI/NotificationsAndSounds.swift +++ b/TelegramUI/NotificationsAndSounds.swift @@ -415,7 +415,7 @@ public func notificationsAndSoundsController(account: Account) -> ViewController inAppSettings = InAppNotificationSettings.defaultSettings } - let controllerState = ItemListControllerState(title: "Notifications", leftNavigationButton: nil, rightNavigationButton: nil) + let controllerState = ItemListControllerState(title: .text("Notifications"), leftNavigationButton: nil, rightNavigationButton: nil) let listState = ItemListNodeState(entries: notificationsAndSoundsEntries(globalSettings: viewSettings, inAppSettings: inAppSettings), style: .blocks) return (controllerState, (listState, arguments)) diff --git a/TelegramUI/PasscodeOptionsController.swift b/TelegramUI/PasscodeOptionsController.swift new file mode 100644 index 0000000000..e82554265a --- /dev/null +++ b/TelegramUI/PasscodeOptionsController.swift @@ -0,0 +1,417 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore +import TelegramLegacyComponents + +private final class PasscodeOptionsControllerArguments { + let turnPasscodeOn: () -> Void + let turnPasscodeOff: () -> Void + let changePasscode: () -> Void + let changePasscodeTimeout: () -> Void + let changeTouchId: (Bool) -> Void + + init(turnPasscodeOn: @escaping () -> Void, turnPasscodeOff: @escaping () -> Void, changePasscode: @escaping () -> Void, changePasscodeTimeout: @escaping () -> Void, changeTouchId: @escaping (Bool) -> Void) { + self.turnPasscodeOn = turnPasscodeOn + self.turnPasscodeOff = turnPasscodeOff + self.changePasscode = changePasscode + self.changePasscodeTimeout = changePasscodeTimeout + self.changeTouchId = changeTouchId + } +} + +private enum PasscodeOptionsSection: Int32 { + case setting + case options +} + +private enum PasscodeOptionsEntry: ItemListNodeEntry { + case togglePasscode(String, Bool) + case changePasscode(String) + case settingInfo(String) + + case autoLock(String, String) + case touchId(String, Bool) + + var section: ItemListSectionId { + switch self { + case .togglePasscode, .changePasscode, .settingInfo: + return PasscodeOptionsSection.setting.rawValue + case .autoLock, .touchId: + return PasscodeOptionsSection.options.rawValue + } + } + + var stableId: Int32 { + switch self { + case .togglePasscode: + return 0 + case .changePasscode: + return 1 + case .settingInfo: + return 2 + case .autoLock: + return 3 + case .touchId: + return 4 + } + } + + static func ==(lhs: PasscodeOptionsEntry, rhs: PasscodeOptionsEntry) -> Bool { + switch lhs { + case let .togglePasscode(text, value): + if case .togglePasscode(text, value) = rhs { + return true + } else { + return false + } + case let .changePasscode(text): + if case .changePasscode(text) = rhs { + return true + } else { + return false + } + case let .settingInfo(text): + if case .settingInfo(text) = rhs { + return true + } else { + return false + } + case let .autoLock(text, value): + if case .autoLock(text, value) = rhs { + return true + } else { + return false + } + case let .touchId(text, value): + if case .touchId(text, value) = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: PasscodeOptionsEntry, rhs: PasscodeOptionsEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(_ arguments: PasscodeOptionsControllerArguments) -> ListViewItem { + switch self { + case let .togglePasscode(title, value): + return ItemListActionItem(title: title, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { + if value { + arguments.turnPasscodeOff() + } else { + arguments.turnPasscodeOn() + } + }) + case let .changePasscode(title): + return ItemListActionItem(title: title, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { + arguments.changePasscode() + }) + case let .settingInfo(text): + return ItemListTextItem(text: .plain(text), sectionId: self.section) + case let .autoLock(title, value): + return ItemListDisclosureItem(title: title, label: value, sectionId: self.section, style: .blocks, action: { + arguments.changePasscodeTimeout() + }) + case let .touchId(title, value): + return ItemListSwitchItem(title: title, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.changeTouchId(value) + }) + } + } +} + +private struct PasscodeOptionsControllerState: Equatable { + static func ==(lhs: PasscodeOptionsControllerState, rhs: PasscodeOptionsControllerState) -> Bool { + return true + } +} + +private struct PasscodeOptionsData: Equatable { + let accessChallenge: PostboxAccessChallengeData + let presentationSettings: PresentationPasscodeSettings + + init(accessChallenge: PostboxAccessChallengeData, presentationSettings: PresentationPasscodeSettings) { + self.accessChallenge = accessChallenge + self.presentationSettings = presentationSettings + } + + static func ==(lhs: PasscodeOptionsData, rhs: PasscodeOptionsData) -> Bool { + return lhs.accessChallenge == rhs.accessChallenge && lhs.presentationSettings == rhs.presentationSettings + } + + func withUpdatedAccessChallenge(_ accessChallenge: PostboxAccessChallengeData) -> PasscodeOptionsData { + return PasscodeOptionsData(accessChallenge: accessChallenge, presentationSettings: self.presentationSettings) + } + + func withUpdatedPresentationSettings(_ presentationSettings: PresentationPasscodeSettings) -> PasscodeOptionsData { + return PasscodeOptionsData(accessChallenge: self.accessChallenge, presentationSettings: presentationSettings) + } +} + +private func autolockStringForTimeout(_ timeout: Int32?) -> String { + if let timeout = timeout { + if timeout == 10 { + return "If away for 10 seconds" + } else if timeout == 1 * 60 { + return "If away for 1 min" + } else if timeout == 5 * 60 { + return "If away for 5 min" + } else if timeout == 1 * 60 * 60 { + return "If away for 1 hour" + } else if timeout == 5 * 60 * 60 { + return "If away for 5 hours" + } else { + return "" + } + } else { + return "Disabled" + } +} + +private func passcodeOptionsControllerEntries(state: PasscodeOptionsControllerState, passcodeOptionsData: PasscodeOptionsData) -> [PasscodeOptionsEntry] { + var entries: [PasscodeOptionsEntry] = [] + + switch passcodeOptionsData.accessChallenge { + case .none: + entries.append(.togglePasscode("Turn Passcode On", false)) + entries.append(.settingInfo("When you set up an additional passcode, a lock icon will appear on the chats page. Tap it to lock and unlock the app.\n\nNote: if you forget the passcode, you'll need to delete and reinstall the app. All secret chats will be lost.")) + case .numericalPassword, .plaintextPassword: + entries.append(.togglePasscode("Turn Passcode Off", true)) + entries.append(.changePasscode("Change Passcode")) + entries.append(.settingInfo("When you set up an additional passcode, a lock icon will appear on the chats page. Tap it to lock and unlock the app.\n\nNote: if you forget the passcode, you'll need to delete and reinstall the app. All secret chats will be lost.")) + entries.append(.autoLock("Auto-Lock", autolockStringForTimeout(passcodeOptionsData.presentationSettings.autolockTimeout))) + entries.append(.touchId("Unlock with Touch ID", passcodeOptionsData.presentationSettings.enableBiometrics)) + } + + return entries +} + +func passcodeOptionsController(account: Account) -> ViewController { + let initialState = PasscodeOptionsControllerState() + + let statePromise = ValuePromise(initialState, ignoreRepeated: true) + let stateValue = Atomic(value: initialState) + let updateState: ((PasscodeOptionsControllerState) -> PasscodeOptionsControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments) -> Void)? + + let actionsDisposable = DisposableSet() + + let passcodeOptionsDataPromise = Promise() + passcodeOptionsDataPromise.set(combineLatest(account.postbox.modify { modifier -> PostboxAccessChallengeData in + return modifier.getAccessChallengeData() + }, account.postbox.preferencesView(keys: [ApplicationSpecificPreferencesKeys.presentationPasscodeSettings]) |> take(1)) |> map { accessChallenge, preferences -> PasscodeOptionsData in + return PasscodeOptionsData(accessChallenge: accessChallenge, presentationSettings: (preferences.values[ApplicationSpecificPreferencesKeys.presentationPasscodeSettings] as? PresentationPasscodeSettings) ?? PresentationPasscodeSettings.defaultSettings) + }) + + let arguments = PasscodeOptionsControllerArguments(turnPasscodeOn: { + var dismissImpl: (() -> Void)? + let controller = TGPasscodeEntryController(style: TGPasscodeEntryControllerStyleDefault, mode: TGPasscodeEntryControllerModeSetupSimple, cancelEnabled: true, allowTouchId: false, attemptData: nil, completion: { result in + if let result = result { + let challenge = PostboxAccessChallengeData.numericalPassword(value: result, timeout: nil, attempts: nil) + let _ = account.postbox.modify({ modifier -> Void in + modifier.setAccessChallengeData(challenge) + }).start() + + let _ = (passcodeOptionsDataPromise.get() |> take(1)).start(next: { [weak passcodeOptionsDataPromise] data in + passcodeOptionsDataPromise?.set(.single(data.withUpdatedAccessChallenge(challenge))) + }) + + dismissImpl?() + } else { + dismissImpl?() + } + })! + let legacyController = LegacyController(legacyController: controller, presentation: LegacyControllerPresentation.modal(animateIn: true)) + legacyController.supportedOrientations = .portrait + legacyController.statusBar.statusBarStyle = .White + presentControllerImpl?(legacyController, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + dismissImpl = { [weak legacyController] in + legacyController?.dismiss() + } + }, turnPasscodeOff: { + let actionSheet = ActionSheetController() + actionSheet.setItemGroups([ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "Turn Passcode Off", color: .destructive, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + + let challenge = PostboxAccessChallengeData.none + let _ = account.postbox.modify({ modifier -> Void in + modifier.setAccessChallengeData(challenge) + }).start() + + let _ = (passcodeOptionsDataPromise.get() |> take(1)).start(next: { [weak passcodeOptionsDataPromise] data in + passcodeOptionsDataPromise?.set(.single(data.withUpdatedAccessChallenge(challenge))) + }) + }) + ]), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "Cancel", color: .accent, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ])]) + presentControllerImpl?(actionSheet, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + }, changePasscode: { + var dismissImpl: (() -> Void)? + let controller = TGPasscodeEntryController(style: TGPasscodeEntryControllerStyleDefault, mode: TGPasscodeEntryControllerModeSetupSimple, cancelEnabled: true, allowTouchId: false, attemptData: nil, completion: { result in + if let result = result { + let _ = account.postbox.modify({ modifier -> Void in + var data = modifier.getAccessChallengeData() + data = PostboxAccessChallengeData.numericalPassword(value: result, timeout: data.autolockDeadline, attempts: nil) + modifier.setAccessChallengeData(data) + }).start() + + let _ = (passcodeOptionsDataPromise.get() |> take(1)).start(next: { [weak passcodeOptionsDataPromise] data in + passcodeOptionsDataPromise?.set(.single(data.withUpdatedAccessChallenge(PostboxAccessChallengeData.numericalPassword(value: result, timeout: nil, attempts: nil)))) + }) + + dismissImpl?() + } else { + dismissImpl?() + } + })! + let legacyController = LegacyController(legacyController: controller, presentation: LegacyControllerPresentation.modal(animateIn: true)) + legacyController.supportedOrientations = .portrait + legacyController.statusBar.statusBarStyle = .White + presentControllerImpl?(legacyController, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + dismissImpl = { [weak legacyController] in + legacyController?.dismiss() + } + }, changePasscodeTimeout: { + let actionSheet = ActionSheetController() + var items: [ActionSheetItem] = [] + let setAction: (Int32?) -> Void = { value in + let _ = (passcodeOptionsDataPromise.get() |> take(1)).start(next: { [weak passcodeOptionsDataPromise] data in + passcodeOptionsDataPromise?.set(.single(data.withUpdatedPresentationSettings(data.presentationSettings.withUpdatedAutolockTimeout(value)))) + + let _ = updatePresentationPasscodeSettingsInteractively(postbox: account.postbox, { current in + return current.withUpdatedAutolockTimeout(value) + }).start() + }) + } + var values: [Int32] = [0, 1 * 60, 5 * 60, 1 * 60 * 60, 5 * 60 * 60] + + #if DEBUG + values.append(10) + values.sort() + #endif + + for value in values { + var t: Int32? + if value != 0 { + t = value + } + items.append(ActionSheetButtonItem(title: autolockStringForTimeout(t), color: .accent, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + + setAction(t) + })) + } + + actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "Cancel", color: .accent, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ])]) + presentControllerImpl?(actionSheet, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + }, changeTouchId: { value in + let _ = (passcodeOptionsDataPromise.get() |> take(1)).start(next: { [weak passcodeOptionsDataPromise] data in + passcodeOptionsDataPromise?.set(.single(data.withUpdatedPresentationSettings(data.presentationSettings.withUpdatedEnableBiometrics(value)))) + + let _ = updatePresentationPasscodeSettingsInteractively(postbox: account.postbox, { current in + return current.withUpdatedEnableBiometrics(value) + }).start() + }) + }) + + let signal = combineLatest(statePromise.get(), passcodeOptionsDataPromise.get()) |> deliverOnMainQueue + |> map { state, passcodeOptionsData -> (ItemListControllerState, (ItemListNodeState, PasscodeOptionsEntry.ItemGenerationArguments)) in + + let controllerState = ItemListControllerState(title: .text("Passcode Lock"), leftNavigationButton: nil, rightNavigationButton: nil, animateChanges: false) + let listState = ItemListNodeState(entries: passcodeOptionsControllerEntries(state: state, passcodeOptionsData: passcodeOptionsData), style: .blocks, 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) + } + } + + return controller +} + +public func passcodeOptionsAccessController(account: Account, animateIn: Bool = true, completion: @escaping (Bool) -> Void) -> Signal { + return account.postbox.modify { modifier -> PostboxAccessChallengeData in + return modifier.getAccessChallengeData() + } |> deliverOnMainQueue + |> map { challenge -> ViewController? in + if case .none = challenge { + completion(true) + return nil + } else { + var attemptData: TGPasscodeEntryAttemptData? + if let attempts = challenge.attempts { + attemptData = TGPasscodeEntryAttemptData(numberOfInvalidAttempts: Int(attempts.count), dateOfLastInvalidAttempt: Double(attempts.timestamp)) + } + var dismissImpl: (() -> Void)? + let controller = TGPasscodeEntryController(style: TGPasscodeEntryControllerStyleDefault, mode: TGPasscodeEntryControllerModeVerifySimple, cancelEnabled: true, allowTouchId: false, attemptData: attemptData, completion: { value in + if value != nil { + completion(false) + } + dismissImpl?() + })! + controller.checkCurrentPasscode = { value in + if let value = value { + switch challenge { + case .none: + return true + case let .numericalPassword(code, _, _): + return value == code + case let .plaintextPassword(code, _, _): + return value == code + } + } else { + return false + } + } + controller.updateAttemptData = { attemptData in + let _ = account.postbox.modify({ modifier -> Void in + var attempts: AccessChallengeAttempts? + if let attemptData = attemptData { + attempts = AccessChallengeAttempts(count: Int32(attemptData.numberOfInvalidAttempts), timestamp: Int32(attemptData.dateOfLastInvalidAttempt)) + } + var data = modifier.getAccessChallengeData() + switch data { + case .none: + break + case let .numericalPassword(value, timeout, _): + data = .numericalPassword(value: value, timeout: timeout, attempts: attempts) + case let .plaintextPassword(value, timeout, _): + data = .plaintextPassword(value: value, timeout: timeout, attempts: attempts) + } + modifier.setAccessChallengeData(data) + }).start() + } + let legacyController = LegacyController(legacyController: controller, presentation: LegacyControllerPresentation.modal(animateIn: animateIn)) + legacyController.supportedOrientations = .portrait + legacyController.statusBar.statusBarStyle = .White + dismissImpl = { [weak legacyController] in + legacyController?.dismiss() + } + return legacyController + } + } +} diff --git a/TelegramUI/PeerAvatar.swift b/TelegramUI/PeerAvatar.swift index 76a9d2b932..054b343aaf 100644 --- a/TelegramUI/PeerAvatar.swift +++ b/TelegramUI/PeerAvatar.swift @@ -19,8 +19,14 @@ private let roundCorners = { () -> UIImage in return image }() -func peerAvatarImage(account: Account, peer: Peer, displayDimensions: CGSize = CGSize(width: 60.0, height: 60.0)) -> Signal? { - if let smallProfileImage = peer.smallProfileImage { +func peerAvatarImage(account: Account, peer: Peer, temporaryRepresentation: TelegramMediaImageRepresentation?, displayDimensions: CGSize = CGSize(width: 60.0, height: 60.0)) -> Signal? { + var smallProfileImage: TelegramMediaImageRepresentation? + if let temporaryRepresentation = temporaryRepresentation { + smallProfileImage = temporaryRepresentation + } else { + smallProfileImage = peer.smallProfileImage + } + if let smallProfileImage = smallProfileImage { let resourceData = account.postbox.mediaBox.resourceData(smallProfileImage.resource) let imageData = resourceData |> take(1) @@ -41,7 +47,7 @@ func peerAvatarImage(account: Account, peer: Peer, displayDimensions: CGSize = C }, completed: { subscriber.putCompletion() }) - let fetchedDataDisposable = account.postbox.mediaBox.fetchedResource(smallProfileImage.resource).start() + let fetchedDataDisposable = account.postbox.mediaBox.fetchedResource(smallProfileImage.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .generic)).start() return ActionDisposable { resourceDataDisposable.dispose() fetchedDataDisposable.dispose() diff --git a/TelegramUI/PeerAvatarImageGalleryItem.swift b/TelegramUI/PeerAvatarImageGalleryItem.swift new file mode 100644 index 0000000000..34a93c4968 --- /dev/null +++ b/TelegramUI/PeerAvatarImageGalleryItem.swift @@ -0,0 +1,209 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore + +class PeerAvatarImageGalleryItem: GalleryItem { + let account: Account + let entry: AvatarGalleryEntry + + init(account: Account, entry: AvatarGalleryEntry) { + self.account = account + self.entry = entry + } + + func node() -> GalleryItemNode { + let node = PeerAvatarImageGalleryItemNode(account: self.account) + + /*if let location = self.location { + node._title.set(.single("\(location.index + 1) of \(location.count)")) + }*/ + + node.setEntry(self.entry) + + return node + } + + func updateNode(node: GalleryItemNode) { + if let node = node as? PeerAvatarImageGalleryItemNode { + /*if let location = self.location { + node._title.set(.single("\(location.index + 1) of \(location.count)")) + }*/ + + node.setEntry(self.entry) + } + } +} + +final class PeerAvatarImageGalleryItemNode: ZoomableContentGalleryItemNode { + private let account: Account + + private var entry: AvatarGalleryEntry? + + private let imageNode: TransformImageNode + fileprivate let _ready = Promise() + fileprivate let _title = Promise() + //private let footerContentNode: ChatItemGalleryFooterContentNode + + private var fetchDisposable = MetaDisposable() + + init(account: Account) { + self.account = account + + self.imageNode = TransformImageNode() + //self.footerContentNode = ChatItemGalleryFooterContentNode(account: account) + + super.init() + + self.imageNode.imageUpdated = { [weak self] in + self?._ready.set(.single(Void())) + } + + self.imageNode.view.contentMode = .scaleAspectFill + self.imageNode.clipsToBounds = true + } + + deinit { + self.fetchDisposable.dispose() + } + + override func ready() -> Signal { + return self._ready.get() + } + + override func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) + } + + fileprivate func setEntry(_ entry: AvatarGalleryEntry) { + if self.entry != entry { + self.entry = entry + + if let largestSize = largestImageRepresentation(entry.representations) { + let displaySize = largestSize.dimensions.fitted(CGSize(width: 1280.0, height: 1280.0)).dividedByScreenScale().integralFloor + self.imageNode.alphaTransitionOnFirstUpdate = false + self.imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: displaySize, boundingSize: displaySize, intrinsicInsets: UIEdgeInsets()))() + self.imageNode.setSignal(account: account, signal: chatAvatarGalleryPhoto(account: account, representations: entry.representations), dispatchOnDisplayLink: false) + self.zoomableContent = (largestSize.dimensions, self.imageNode) + self.fetchDisposable.set(account.postbox.mediaBox.fetchedResource(largestSize.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .generic)).start()) + } else { + self._ready.set(.single(Void())) + } + } + } + + override func animateIn(from node: ASDisplayNode) { + var transformedFrame = node.view.convert(node.view.bounds, to: self.imageNode.view) + let transformedSuperFrame = node.view.convert(node.view.bounds, to: self.imageNode.view.superview) + let transformedSelfFrame = node.view.convert(node.view.bounds, to: self.view) + let transformedCopyViewFinalFrame = self.imageNode.view.convert(self.imageNode.view.bounds, to: self.view) + + let copyView = node.view.snapshotContentTree()! + + self.view.insertSubview(copyView, belowSubview: self.scrollView) + copyView.frame = transformedSelfFrame + + copyView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak copyView] _ in + copyView?.removeFromSuperview() + }) + + copyView.layer.animatePosition(from: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY), to: CGPoint(x: transformedCopyViewFinalFrame.midX, y: transformedCopyViewFinalFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + let scale = CGSize(width: transformedCopyViewFinalFrame.size.width / transformedSelfFrame.size.width, height: transformedCopyViewFinalFrame.size.height / transformedSelfFrame.size.height) + copyView.layer.animate(from: NSValue(caTransform3D: CATransform3DIdentity), to: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false) + + self.imageNode.layer.animatePosition(from: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), to: self.imageNode.layer.position, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring) + self.imageNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.07) + + transformedFrame.origin = CGPoint() + //self.imageNode.layer.animateBounds(from: transformedFrame, to: self.imageNode.layer.bounds, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring) + + let transform = CATransform3DScale(self.imageNode.layer.transform, transformedFrame.size.width / self.imageNode.layer.bounds.size.width, transformedFrame.size.height / self.imageNode.layer.bounds.size.height, 1.0) + self.imageNode.layer.animate(from: NSValue(caTransform3D: transform), to: NSValue(caTransform3D: self.imageNode.layer.transform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25) + + self.imageNode.clipsToBounds = true + self.imageNode.layer.animate(from: (self.imageNode.frame.width / 2.0) as NSNumber, to: 0.0 as NSNumber, keyPath: "cornerRadius", timingFunction: kCAMediaTimingFunctionDefault, duration: 0.18, removeOnCompletion: false, completion: { [weak self] value in + if value { + self?.imageNode.clipsToBounds = false + } + }) + } + + override func animateOut(to node: ASDisplayNode, completion: @escaping () -> Void) { + var transformedFrame = node.view.convert(node.view.bounds, to: self.imageNode.view) + let transformedSuperFrame = node.view.convert(node.view.bounds, to: self.imageNode.view.superview) + let transformedSelfFrame = node.view.convert(node.view.bounds, to: self.view) + let transformedCopyViewInitialFrame = self.imageNode.view.convert(self.imageNode.view.bounds, to: self.view) + + var positionCompleted = false + var boundsCompleted = false + var copyCompleted = false + + let copyView = node.view.snapshotContentTree()! + + self.view.insertSubview(copyView, belowSubview: self.scrollView) + copyView.frame = transformedSelfFrame + + let intermediateCompletion = { [weak copyView] in + if positionCompleted && boundsCompleted && copyCompleted { + copyView?.removeFromSuperview() + completion() + } + } + + let durationFactor = 1.0 + + copyView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1 * durationFactor, removeOnCompletion: false) + + copyView.layer.animatePosition(from: CGPoint(x: transformedCopyViewInitialFrame.midX, y: transformedCopyViewInitialFrame.midY), to: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY), duration: 0.25 * durationFactor, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) + let scale = CGSize(width: transformedCopyViewInitialFrame.size.width / transformedSelfFrame.size.width, height: transformedCopyViewInitialFrame.size.height / transformedSelfFrame.size.height) + copyView.layer.animate(from: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), to: NSValue(caTransform3D: CATransform3DIdentity), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25 * durationFactor, removeOnCompletion: false, completion: { _ in + copyCompleted = true + intermediateCompletion() + }) + + self.imageNode.layer.animatePosition(from: self.imageNode.layer.position, to: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), duration: 0.25 * durationFactor, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in + positionCompleted = true + intermediateCompletion() + }) + + self.imageNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25 * durationFactor, removeOnCompletion: false) + + transformedFrame.origin = CGPoint() + + /*self.imageNode.layer.animateBounds(from: self.imageNode.layer.bounds, to: transformedFrame, duration: 0.25 * durationFactor, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in + boundsCompleted = true + intermediateCompletion() + })*/ + + let transform = CATransform3DScale(self.imageNode.layer.transform, transformedFrame.size.width / self.imageNode.layer.bounds.size.width, transformedFrame.size.height / self.imageNode.layer.bounds.size.height, 1.0) + self.imageNode.layer.animate(from: NSValue(caTransform3D: self.imageNode.layer.transform), to: NSValue(caTransform3D: transform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25 * durationFactor, removeOnCompletion: false, completion: { _ in + boundsCompleted = true + intermediateCompletion() + }) + + self.imageNode.clipsToBounds = true + self.imageNode.layer.animate(from: 0.0 as NSNumber, to: (self.imageNode.frame.width / 2.0) as NSNumber, keyPath: "cornerRadius", timingFunction: kCAMediaTimingFunctionDefault, duration: 0.18 * durationFactor, removeOnCompletion: false) + } + + override func visibilityUpdated(isVisible: Bool) { + super.visibilityUpdated(isVisible: isVisible) + + /*if let (account, media) = self.accountAndEntry, let file = media as? TelegramMediaFile { + if isVisible { + self.fetchDisposable.set(account.postbox.mediaBox.fetchedResource(file.resource).start()) + } else { + self.fetchDisposable.set(nil) + } + }*/ + } + + override func title() -> Signal { + return self._title.get() + } + + /*override func footerContent() -> Signal { + return .single(self.footerContentNode) + }*/ +} diff --git a/TelegramUI/PeerMediaAudioPlaylist.swift b/TelegramUI/PeerMediaAudioPlaylist.swift index 49fe73110b..40f27c0a63 100644 --- a/TelegramUI/PeerMediaAudioPlaylist.swift +++ b/TelegramUI/PeerMediaAudioPlaylist.swift @@ -121,7 +121,7 @@ func peerMessageHistoryAudioPlaylist(account: Account, messageId: MessageId) -> for media in message.media { if let file = media as? TelegramMediaFile { if file.isVoice { - tagMask = .Voice + tagMask = .VoiceOrInstantVideo } else { tagMask = .Music } diff --git a/TelegramUI/PeerMediaCollectionController.swift b/TelegramUI/PeerMediaCollectionController.swift index 5166bed329..7f3554958d 100644 --- a/TelegramUI/PeerMediaCollectionController.swift +++ b/TelegramUI/PeerMediaCollectionController.swift @@ -80,7 +80,11 @@ public class PeerMediaCollectionController: ViewController { if let file = galleryMedia as? TelegramMediaFile, file.mimeType == "audio/mpeg" { } else { - let gallery = GalleryController(account: strongSelf.account, messageId: id) + let gallery = GalleryController(account: strongSelf.account, messageId: id, replaceRootController: { controller, ready in + if let strongSelf = self { + (strongSelf.navigationController as? NavigationController)?.replaceTopController(controller, animated: false, ready: ready) + } + }) strongSelf.galleryHiddenMesageAndMediaDisposable.set(gallery.hiddenMedia.start(next: { [weak strongSelf] messageIdAndMedia in if let strongSelf = strongSelf { @@ -181,6 +185,7 @@ public class PeerMediaCollectionController: ViewController { } }, sendMessage: { _ in },sendSticker: { _ in + }, sendGif: { _ in }, requestMessageActionCallback: { _ in }, openUrl: { _ in }, shareCurrentLocation: { @@ -189,7 +194,8 @@ public class PeerMediaCollectionController: ViewController { }, openInstantPage: { _ in }, openHashtag: {_ in }, updateInputState: { _ in - }) + }, openMessageShareMenu: { _ in + }, presentController: { _ in }) self.controllerInteraction = controllerInteraction diff --git a/TelegramUI/PeerSelectionController.swift b/TelegramUI/PeerSelectionController.swift index 29cf6fd4c5..3492e3360d 100644 --- a/TelegramUI/PeerSelectionController.swift +++ b/TelegramUI/PeerSelectionController.swift @@ -139,7 +139,7 @@ public final class PeerSelectionController: ViewController { } } - override open func dismiss() { - self.peerSelectionNode.animateOut() + override open func dismiss(completion: (() -> Void)? = nil) { + self.peerSelectionNode.animateOut(completion: completion) } } diff --git a/TelegramUI/PeerSelectionControllerNode.swift b/TelegramUI/PeerSelectionControllerNode.swift index 171f0e6774..bb21eeb3d7 100644 --- a/TelegramUI/PeerSelectionControllerNode.swift +++ b/TelegramUI/PeerSelectionControllerNode.swift @@ -129,11 +129,12 @@ final class PeerSelectionControllerNode: ASDisplayNode { }) } - func animateOut() { + func animateOut(completion: (() -> Void)? = nil) { 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() } + completion?() }) } } diff --git a/TelegramUI/PhotoResources.swift b/TelegramUI/PhotoResources.swift index 7c2162121d..b1ecbfe178 100644 --- a/TelegramUI/PhotoResources.swift +++ b/TelegramUI/PhotoResources.swift @@ -7,6 +7,11 @@ import ImageIO import TelegramUIPrivateModule import TelegramCore +private enum ResourceFileData { + case data(Data) + case file(path: String, size: Int) +} + func largestRepresentationForPhoto(_ photo: TelegramMediaImage) -> TelegramMediaImageRepresentation? { return photo.representationForDisplayAtSize(CGSize(width: 1280.0, height: 1280.0)) } @@ -20,8 +25,8 @@ private func chatMessagePhotoDatas(account: Account, photo: TelegramMediaImage, 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 fetchedFullSize = account.postbox.mediaBox.fetchedResource(largestRepresentation.resource) + let fetchedThumbnail = account.postbox.mediaBox.fetchedResource(smallestRepresentation.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .image)) + let fetchedFullSize = account.postbox.mediaBox.fetchedResource(largestRepresentation.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .image)) let thumbnail = Signal { subscriber in let fetchedDisposable = fetchedThumbnail.start() @@ -71,24 +76,22 @@ private func chatMessagePhotoDatas(account: Account, photo: TelegramMediaImage, } } -private func chatMessageFileDatas(account: Account, file: TelegramMediaFile, progressive: Bool = false) -> Signal<(Data?, (Data, String)?, Bool), NoError> { +private func chatMessageFileDatas(account: Account, file: TelegramMediaFile, pathExtension: String? = nil, progressive: Bool = false) -> Signal<(Data?, String?, Bool), NoError> { if let smallestRepresentation = smallestImageRepresentation(file.previewRepresentations) { let thumbnailResource = smallestRepresentation.resource let fullSizeResource = file.resource - let maybeFullSize = account.postbox.mediaBox.resourceData(fullSizeResource) + let maybeFullSize = account.postbox.mediaBox.resourceData(fullSizeResource, pathExtension: pathExtension) - let signal = maybeFullSize |> take(1) |> mapToSignal { maybeData -> Signal<(Data?, (Data, String)?, Bool), NoError> in + let signal = maybeFullSize |> take(1) |> mapToSignal { maybeData -> Signal<(Data?, String?, Bool), NoError> in if maybeData.complete { - let loadedData: Data? = try? Data(contentsOf: URL(fileURLWithPath: maybeData.path), options: []) - - return .single((nil, loadedData == nil ? nil : (loadedData!, maybeData.path), true)) + return .single((nil, maybeData.path, true)) } else { - let fetchedThumbnail = account.postbox.mediaBox.fetchedResource(thumbnailResource) + let fetchedThumbnail = account.postbox.mediaBox.fetchedResource(thumbnailResource, tag: TelegramMediaResourceFetchTag(statsCategory: .file)) let thumbnail = Signal { subscriber in let fetchedDisposable = fetchedThumbnail.start() - let thumbnailDisposable = account.postbox.mediaBox.resourceData(thumbnailResource).start(next: { next in + let thumbnailDisposable = account.postbox.mediaBox.resourceData(thumbnailResource, pathExtension: pathExtension).start(next: { next in subscriber.putNext(next.size == 0 ? nil : try? Data(contentsOf: URL(fileURLWithPath: next.path), options: [])) }, error: subscriber.putError, completed: subscriber.putCompletion) @@ -99,14 +102,13 @@ private func chatMessageFileDatas(account: Account, file: TelegramMediaFile, pro } - let fullSizeDataAndPath = account.postbox.mediaBox.resourceData(fullSizeResource, option: !progressive ? .complete(waitUntilFetchStatus: false) : .incremental(waitUntilFetchStatus: false)) |> map { next -> ((Data, String)?, Bool) in - let data = next.size == 0 ? nil : try? Data(contentsOf: URL(fileURLWithPath: next.path), options: .mappedIfSafe) - return (data == nil ? nil : (data!, next.path), next.complete) + let fullSizeDataAndPath = account.postbox.mediaBox.resourceData(fullSizeResource, option: !progressive ? .complete(waitUntilFetchStatus: false) : .incremental(waitUntilFetchStatus: false)) |> map { next -> (String?, Bool) in + return (next.size == 0 ? nil : next.path, next.complete) } return thumbnail |> mapToSignal { thumbnailData in - return fullSizeDataAndPath |> map { (dataAndPath, complete) in - return (thumbnailData, dataAndPath, complete) + return fullSizeDataAndPath |> map { (dataPath, complete) in + return (thumbnailData, dataPath, complete) } } } @@ -131,7 +133,7 @@ private func chatMessageVideoDatas(account: Account, file: TelegramMediaFile) -> return .single((nil, loadedData == nil ? nil : (loadedData!, maybeData.path), true)) } else { - let fetchedThumbnail = account.postbox.mediaBox.fetchedResource(thumbnailResource) + let fetchedThumbnail = account.postbox.mediaBox.fetchedResource(thumbnailResource, tag: TelegramMediaResourceFetchTag(statsCategory: .video)) let thumbnail = Signal { subscriber in let fetchedDisposable = fetchedThumbnail.start() @@ -529,7 +531,7 @@ private func chatMessagePhotoThumbnailDatas(account: Account, photo: TelegramMed 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 fetchedThumbnail = account.postbox.mediaBox.fetchedResource(smallestRepresentation.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .image)) let thumbnail = Signal { subscriber in let fetchedDisposable = fetchedThumbnail.start() @@ -842,6 +844,75 @@ func mediaGridMessagePhoto(account: Account, photo: TelegramMediaImage) -> Signa } } +func gifPaneVideoThumbnail(account: Account, video: TelegramMediaFile) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { + if let smallestRepresentation = smallestImageRepresentation(video.previewRepresentations) { + let thumbnailResource = smallestRepresentation.resource + + let thumbnail = Signal { subscriber in + let data = account.postbox.mediaBox.resourceData(thumbnailResource).start(next: { data in + subscriber.putNext(data) + }, completed: { + subscriber.putCompletion() + }) + let fetched = account.postbox.mediaBox.fetchedResource(thumbnailResource, tag: TelegramMediaResourceFetchTag(statsCategory: .video)).start() + return ActionDisposable { + data.dispose() + fetched.dispose() + } + } + + return thumbnail + |> map { data in + let thumbnailData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) + return { arguments in + let context = DrawingContext(size: arguments.drawingSize, clear: true) + let drawingRect = arguments.drawingRect + let fittedSize = arguments.imageSize.aspectFilled(arguments.boundingSize).fitted(arguments.imageSize) + let fittedRect = CGRect(origin: CGPoint(x: drawingRect.origin.x + (drawingRect.size.width - fittedSize.width) / 2.0, y: drawingRect.origin.y + (drawingRect.size.height - fittedSize.height) / 2.0), size: fittedSize) + + var thumbnailImage: CGImage? + if let thumbnailData = thumbnailData, let imageSource = CGImageSourceCreateWithData(thumbnailData as CFData, nil), let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) { + thumbnailImage = image + } + + var blurredThumbnailImage: UIImage? + if let thumbnailImage = thumbnailImage { + let thumbnailSize = CGSize(width: thumbnailImage.width, height: thumbnailImage.height) + let thumbnailContextSize = thumbnailSize.aspectFitted(CGSize(width: 150.0, height: 150.0)) + let thumbnailContext = DrawingContext(size: thumbnailContextSize, scale: 1.0) + thumbnailContext.withFlippedContext { c in + c.interpolationQuality = .none + c.draw(thumbnailImage, in: CGRect(origin: CGPoint(), size: thumbnailContextSize)) + } + telegramFastBlur(Int32(thumbnailContextSize.width), Int32(thumbnailContextSize.height), Int32(thumbnailContext.bytesPerRow), thumbnailContext.bytes) + + blurredThumbnailImage = thumbnailContext.generateImage() + } + + context.withFlippedContext { c in + c.setBlendMode(.copy) + if arguments.boundingSize != arguments.imageSize { + c.fill(arguments.drawingRect) + } + + c.setBlendMode(.copy) + if let blurredThumbnailImage = blurredThumbnailImage, let cgImage = blurredThumbnailImage.cgImage { + c.interpolationQuality = .low + c.draw(cgImage, in: fittedRect) + c.setBlendMode(.normal) + } + } + + addCorners(context, arguments: arguments) + + return context + } + } + } else { + return .never() + } +} + func mediaGridMessageVideo(account: Account, video: TelegramMediaFile) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { let signal = chatMessageVideoDatas(account: account, file: video) @@ -858,9 +929,9 @@ func mediaGridMessageVideo(account: Account, video: TelegramMediaFile) -> Signal 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.0 as CFData, nil), let image = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) { + //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.0 as CFData, nil), let image = CGImageSourceCreateImageAtIndex(imageSource, 0, options as CFDictionary) { fullSizeImage = image } } else { @@ -928,9 +999,9 @@ func chatMessagePhotoStatus(account: Account, photo: TelegramMediaImage) -> Sign } } -func chatMessagePhotoInteractiveFetched(account: Account, photo: TelegramMediaImage) -> Signal { +func chatMessagePhotoInteractiveFetched(account: Account, photo: TelegramMediaImage) -> Signal { if let largestRepresentation = largestRepresentationForPhoto(photo) { - return account.postbox.mediaBox.fetchedResource(largestRepresentation.resource) + return account.postbox.mediaBox.fetchedResource(largestRepresentation.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .image)) } else { return .never() } @@ -957,7 +1028,7 @@ func chatWebpageSnippetPhotoData(account: Account, photo: TelegramMediaImage) -> }, completed: { subscriber.putCompletion() })) - disposable.add(account.postbox.mediaBox.fetchedResource(closestRepresentation.resource).start()) + disposable.add(account.postbox.mediaBox.fetchedResource(closestRepresentation.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .image)).start()) return disposable } } else { @@ -1007,107 +1078,14 @@ func chatWebpageSnippetPhoto(account: Account, photo: TelegramMediaImage) -> Sig } func chatMessageVideo(account: Account, video: TelegramMediaFile) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { - let signal = chatMessageFileDatas(account: account, file: video) - - return signal |> map { (thumbnailData, fullSizeDataAndPath, fullSizeComplete) in - return { arguments in - assertNotOnMainThread() - let context = DrawingContext(size: arguments.drawingSize, clear: true) - if arguments.drawingSize.width.isLessThanOrEqualTo(0.0) || arguments.drawingSize.height.isLessThanOrEqualTo(0.0) { - return context - } - - let drawingRect = arguments.drawingRect - let fittedSize = arguments.imageSize.aspectFilled(arguments.boundingSize).fitted(arguments.imageSize) - let fittedRect = CGRect(origin: CGPoint(x: drawingRect.origin.x + (drawingRect.size.width - fittedSize.width) / 2.0, y: drawingRect.origin.y + (drawingRect.size.height - fittedSize.height) / 2.0), size: fittedSize) - - var fullSizeImage: CGImage? - if let fullSizeDataAndPath = fullSizeDataAndPath { - if fullSizeComplete { - if video.mimeType.hasPrefix("video/") { - let tempFilePath = NSTemporaryDirectory() + "\(arc4random()).mov" - - _ = try? FileManager.default.removeItem(atPath: tempFilePath) - _ = try? FileManager.default.linkItem(atPath: fullSizeDataAndPath.1, toPath: tempFilePath) - - let asset = AVAsset(url: URL(fileURLWithPath: tempFilePath)) - let imageGenerator = AVAssetImageGenerator(asset: asset) - imageGenerator.maximumSize = CGSize(width: 800.0, height: 800.0) - imageGenerator.appliesPreferredTrackTransform = true - if let image = try? imageGenerator.copyCGImage(at: CMTime(seconds: 0.0, preferredTimescale: asset.duration.timescale), actualTime: nil) { - fullSizeImage = image - } - } - /*let options: [NSString: NSObject] = [ - kCGImageSourceThumbnailMaxPixelSize: max(fittedSize.width * context.scale, fittedSize.height * context.scale), - kCGImageSourceCreateThumbnailFromImageAlways: true - ] - if let imageSource = CGImageSourceCreateWithData(fullSizeData, nil), image = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options) { - fullSizeImage = image - }*/ - } else { - /*let imageSource = CGImageSourceCreateIncremental(nil) - CGImageSourceUpdateData(imageSource, fullSizeData as CFDataRef, fullSizeData.length >= fullTotalSize) - - var options: [NSString : NSObject!] = [:] - options[kCGImageSourceShouldCache as NSString] = false as NSNumber - if let image = CGImageSourceCreateImageAtIndex(imageSource, 0, options as CFDictionaryRef) { - 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: 1.0, alpha: 0.5).cgColor) - c.fill(arguments.drawingRect) - } - - c.setBlendMode(.copy) - if let blurredThumbnailImage = blurredThumbnailImage, let cgImage = blurredThumbnailImage.cgImage { - c.interpolationQuality = .low - c.draw(cgImage, in: fittedRect) - } - - if let fullSizeImage = fullSizeImage { - c.setBlendMode(.normal) - c.interpolationQuality = .medium - c.draw(fullSizeImage, in: fittedRect) - } - } - - addCorners(context, arguments: arguments) - - return context - } - } + return mediaGridMessageVideo(account: account, video: video) } private func chatSecretMessageVideoData(account: Account, file: TelegramMediaFile) -> Signal { if let smallestRepresentation = smallestImageRepresentation(file.previewRepresentations) { let thumbnailResource = smallestRepresentation.resource - let fetchedThumbnail = account.postbox.mediaBox.fetchedResource(thumbnailResource) + let fetchedThumbnail = account.postbox.mediaBox.fetchedResource(thumbnailResource, tag: TelegramMediaResourceFetchTag(statsCategory: .video)) let thumbnail = Signal { subscriber in let fetchedDisposable = fetchedThumbnail.start() @@ -1233,7 +1211,7 @@ func chatSecretMessageVideo(account: Account, video: TelegramMediaFile) -> Signa func chatMessageImageFile(account: Account, file: TelegramMediaFile, progressive: Bool = false) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { let signal = chatMessageFileDatas(account: account, file: file, progressive: progressive) - return signal |> map { (thumbnailData, fullSizeDataAndPath, fullSizeComplete) in + return signal |> map { (thumbnailData, fullSizePath, fullSizeComplete) in return { arguments in assertNotOnMainThread() let context = DrawingContext(size: arguments.drawingSize, clear: true) @@ -1243,23 +1221,16 @@ func chatMessageImageFile(account: Account, file: TelegramMediaFile, progressive 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 fullSizeDataAndPath = fullSizeDataAndPath { + if let fullSizePath = fullSizePath { 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(fullSizeDataAndPath.0 as CFData, nil), let image = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options) { + if let imageSource = CGImageSourceCreateWithURL(URL(fileURLWithPath: fullSizePath) as CFURL, nil), let image = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options) { fullSizeImage = image } } else if progressive { - let imageSource = CGImageSourceCreateIncremental(nil) - CGImageSourceUpdateData(imageSource, fullSizeDataAndPath.0 as CFData, fullSizeComplete) - - let options = NSMutableDictionary() - options[kCGImageSourceShouldCache as NSString] = false as NSNumber - if let image = CGImageSourceCreateImageAtIndex(imageSource, 0, options as CFDictionary) { - fullSizeImage = image - } + assertionFailure() } } @@ -1312,10 +1283,161 @@ func chatMessageFileStatus(account: Account, file: TelegramMediaFile) -> Signal< return account.postbox.mediaBox.resourceStatus(file.resource) } -func chatMessageFileInteractiveFetched(account: Account, file: TelegramMediaFile) -> Signal { - return account.postbox.mediaBox.fetchedResource(file.resource) +func chatMessageFileInteractiveFetched(account: Account, file: TelegramMediaFile) -> Signal { + return account.postbox.mediaBox.fetchedResource(file.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .file)) } func chatMessageFileCancelInteractiveFetch(account: Account, file: TelegramMediaFile) { account.postbox.mediaBox.cancelInteractiveResourceFetch(file.resource) } + +private func avatarGalleryPhotoDatas(account: Account, representations: [TelegramMediaImageRepresentation]) -> Signal<(Data?, Data?, Bool), NoError> { + if let smallestRepresentation = smallestImageRepresentation(representations), let largestRepresentation = largestImageRepresentation(representations) { + let autoFetchFullSize = false + let maybeFullSize = account.postbox.mediaBox.resourceData(largestRepresentation.resource) + + 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, tag: TelegramMediaResourceFetchTag(statsCategory: .image)) + let fetchedFullSize = account.postbox.mediaBox.fetchedResource(largestRepresentation.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .image)) + + 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> + + if autoFetchFullSize { + fullSizeData = Signal<(Data?, Bool), NoError> { subscriber in + let fetchedFullSizeDisposable = fetchedFullSize.start() + let fullSizeDisposable = account.postbox.mediaBox.resourceData(largestRepresentation.resource).start(next: { next in + subscriber.putNext((next.size == 0 ? nil : try? Data(contentsOf: URL(fileURLWithPath: next.path), options: []), next.complete)) + }, error: subscriber.putError, completed: subscriber.putCompletion) + + return ActionDisposable { + fetchedFullSizeDisposable.dispose() + fullSizeDisposable.dispose() + } + } + } else { + fullSizeData = account.postbox.mediaBox.resourceData(largestRepresentation.resource) + |> 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 chatAvatarGalleryPhoto(account: Account, representations: [TelegramMediaImageRepresentation]) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { + let signal = avatarGalleryPhotoDatas(account: account, representations: representations) + + 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 + } + } +} diff --git a/TelegramUI/PreferencesKeys.swift b/TelegramUI/PreferencesKeys.swift index e621b93c3b..32d9788601 100644 --- a/TelegramUI/PreferencesKeys.swift +++ b/TelegramUI/PreferencesKeys.swift @@ -2,9 +2,17 @@ import Foundation import TelegramCore private enum ApplicationSpecificPreferencesKeyValues: Int32 { - case inAppNotificationSettings + case inAppNotificationSettings = 0 + case presentationPasscodeSettings = 1 + case automaticMediaDownloadSettings = 2 + case generatedMediaStoreSettings = 3 + case voiceCallSettings = 4 } -struct ApplicationSpecificPreferencesKeys { +public struct ApplicationSpecificPreferencesKeys { static let inAppNotificationSettings = applicationSpecificPreferencesKey(ApplicationSpecificPreferencesKeyValues.inAppNotificationSettings.rawValue) + public static let presentationPasscodeSettings = applicationSpecificPreferencesKey(ApplicationSpecificPreferencesKeyValues.presentationPasscodeSettings.rawValue) + public static let automaticMediaDownloadSettings = applicationSpecificPreferencesKey(ApplicationSpecificPreferencesKeyValues.automaticMediaDownloadSettings.rawValue) + public static let generatedMediaStoreSettings = applicationSpecificPreferencesKey(ApplicationSpecificPreferencesKeyValues.generatedMediaStoreSettings.rawValue) + public static let voiceCallSettings = applicationSpecificPreferencesKey(ApplicationSpecificPreferencesKeyValues.voiceCallSettings.rawValue) } diff --git a/TelegramUI/PreparedChatHistoryViewTransition.swift b/TelegramUI/PreparedChatHistoryViewTransition.swift index 52d62286df..6d0296dc86 100644 --- a/TelegramUI/PreparedChatHistoryViewTransition.swift +++ b/TelegramUI/PreparedChatHistoryViewTransition.swift @@ -102,6 +102,8 @@ func preparedChatHistoryViewTransition(from fromView: ChatHistoryView?, to toVie adjustedUpdateItems.append(ChatHistoryViewTransitionUpdateEntry(index: adjustedIndex, previousIndex: adjustedPreviousIndex, entry: entry, directionHint: directionHint)) } + var scrolledToIndex: MessageIndex? + if let scrollPosition = scrollPosition { switch scrollPosition { case let .Unread(unreadIndex): @@ -136,6 +138,9 @@ func preparedChatHistoryViewTransition(from fromView: ChatHistoryView?, to toVie } } case let .Index(scrollIndex, position, directionHint, animated): + if case .Center = position { + scrolledToIndex = scrollIndex + } var index = toView.filteredEntries.count - 1 for entry in toView.filteredEntries { if entry.index >= scrollIndex { @@ -158,7 +163,7 @@ func preparedChatHistoryViewTransition(from fromView: ChatHistoryView?, to toVie } } - subscriber.putNext(ChatHistoryViewTransition(historyView: toView, deleteItems: adjustedDeleteIndices, insertEntries: adjustedIndicesAndItems, updateEntries: adjustedUpdateItems, options: options, scrollToItem: scrollToItem, stationaryItemRange: stationaryItemRange, initialData: initialData, keyboardButtonsMessage: keyboardButtonsMessage, cachedData: cachedData)) + subscriber.putNext(ChatHistoryViewTransition(historyView: toView, deleteItems: adjustedDeleteIndices, insertEntries: adjustedIndicesAndItems, updateEntries: adjustedUpdateItems, options: options, scrollToItem: scrollToItem, stationaryItemRange: stationaryItemRange, initialData: initialData, keyboardButtonsMessage: keyboardButtonsMessage, cachedData: cachedData, scrolledToIndex: scrolledToIndex)) subscriber.putCompletion() return EmptyDisposable diff --git a/TelegramUI/PresenceStrings.swift b/TelegramUI/PresenceStrings.swift index aede1fbef1..cc2e7d209a 100644 --- a/TelegramUI/PresenceStrings.swift +++ b/TelegramUI/PresenceStrings.swift @@ -86,6 +86,45 @@ func stringForUserPresence(day: RelativeTimestampFormatDay, hours: Int32, minute return "last seen \(dayString) at \(stringForTime(hours: hours, minutes: minutes))" } +private func humanReadableStringForTimestamp(day: RelativeTimestampFormatDay, hours: Int32, minutes: Int32) -> String { + let dayString: String + switch day { + case .today: + dayString = "today" + case .yesterday: + dayString = "yesterday" + } + return "\(dayString) at \(stringForTime(hours: hours, minutes: minutes))" +} + +func humanReadableStringForTimestamp(timestamp: Int32) -> String { + var t: time_t = time_t(timestamp) + var timeinfo: tm = tm() + localtime_r(&t, &timeinfo) + + let timestampNow = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) + var now: time_t = time_t(timestampNow) + var timeinfoNow: tm = tm() + localtime_r(&now, &timeinfoNow) + + if timeinfo.tm_year != timeinfoNow.tm_year { + return "\(stringForTimestamp(day: timeinfo.tm_mday, month: timeinfo.tm_mon + 1, year: timeinfo.tm_year))" + } + + let dayDifference = timeinfo.tm_yday - timeinfoNow.tm_yday + if dayDifference == 0 || dayDifference == -1 { + let day: RelativeTimestampFormatDay + if dayDifference == 0 { + day = .today + } else { + day = .yesterday + } + return humanReadableStringForTimestamp(day: day, hours: timeinfo.tm_hour, minutes: timeinfo.tm_min) + } else { + return "\(stringForTimestamp(day: timeinfo.tm_mday, month: timeinfo.tm_mon + 1, year: timeinfo.tm_year))" + } +} + enum RelativeUserPresenceLastSeen { case justNow case minutesAgo(Int32) diff --git a/TelegramUI/PresentationPasscodeSettings.swift b/TelegramUI/PresentationPasscodeSettings.swift new file mode 100644 index 0000000000..a06e10243b --- /dev/null +++ b/TelegramUI/PresentationPasscodeSettings.swift @@ -0,0 +1,65 @@ +import Foundation +import Postbox +import SwiftSignalKit + +public struct PresentationPasscodeSettings: PreferencesEntry, Equatable { + public let enableBiometrics: Bool + public let autolockTimeout: Int32? + + public static var defaultSettings: PresentationPasscodeSettings { + return PresentationPasscodeSettings(enableBiometrics: false, autolockTimeout: nil) + } + + init(enableBiometrics: Bool, autolockTimeout: Int32?) { + self.enableBiometrics = enableBiometrics + self.autolockTimeout = autolockTimeout + } + + public init(decoder: Decoder) { + self.enableBiometrics = (decoder.decodeInt32ForKey("b") as Int32) != 0 + self.autolockTimeout = decoder.decodeInt32ForKey("al") as Int32? + } + + public func encode(_ encoder: Encoder) { + encoder.encodeInt32(self.enableBiometrics ? 1 : 0, forKey: "s") + if let autolockTimeout = self.autolockTimeout { + encoder.encodeInt32(autolockTimeout, forKey: "al") + } else { + encoder.encodeNil(forKey: "al") + } + } + + public func isEqual(to: PreferencesEntry) -> Bool { + if let to = to as? PresentationPasscodeSettings { + return self == to + } else { + return false + } + } + + public static func ==(lhs: PresentationPasscodeSettings, rhs: PresentationPasscodeSettings) -> Bool { + return lhs.enableBiometrics == rhs.enableBiometrics && lhs.autolockTimeout == rhs.autolockTimeout + } + + func withUpdatedEnableBiometrics(_ enableBiometrics: Bool) -> PresentationPasscodeSettings { + return PresentationPasscodeSettings(enableBiometrics: enableBiometrics, autolockTimeout: self.autolockTimeout) + } + + func withUpdatedAutolockTimeout(_ autolockTimeout: Int32?) -> PresentationPasscodeSettings { + return PresentationPasscodeSettings(enableBiometrics: self.enableBiometrics, autolockTimeout: autolockTimeout) + } +} + +func updatePresentationPasscodeSettingsInteractively(postbox: Postbox, _ f: @escaping (PresentationPasscodeSettings) -> PresentationPasscodeSettings) -> Signal { + return postbox.modify { modifier -> Void in + modifier.updatePreferencesEntry(key: ApplicationSpecificPreferencesKeys.presentationPasscodeSettings, { entry in + let currentSettings: PresentationPasscodeSettings + if let entry = entry as? PresentationPasscodeSettings { + currentSettings = entry + } else { + currentSettings = PresentationPasscodeSettings.defaultSettings + } + return f(currentSettings) + }) + } +} diff --git a/TelegramUI/PrivacyAndSecurityController.swift b/TelegramUI/PrivacyAndSecurityController.swift index ef034d812a..ecde644e4e 100644 --- a/TelegramUI/PrivacyAndSecurityController.swift +++ b/TelegramUI/PrivacyAndSecurityController.swift @@ -276,6 +276,7 @@ public func privacyAndSecurityController(account: Account, initialSettings: Sign } var pushControllerImpl: ((ViewController) -> Void)? + var pushControllerInstantImpl: ((ViewController) -> Void)? var presentControllerImpl: ((ViewController) -> Void)? let actionsDisposable = DisposableSet() @@ -361,7 +362,17 @@ public func privacyAndSecurityController(account: Account, initialSettings: Sign } })) }, openPasscode: { - + let _ = passcodeOptionsAccessController(account: account, completion: { animated in + if animated { + pushControllerImpl?(passcodeOptionsController(account: account)) + } else { + pushControllerInstantImpl?(passcodeOptionsController(account: account)) + } + }).start(next: { controller in + if let controller = controller { + presentControllerImpl?(controller) + } + }) }, openTwoStepVerification: { pushControllerImpl?(twoStepVerificationUnlockSettingsController(account: account, mode: .access)) }, openActiveSessions: { @@ -434,7 +445,7 @@ public func privacyAndSecurityController(account: Account, initialSettings: Sign rightNavigationButton = ItemListNavigationButton(title: "", style: .activity, enabled: true, action: {}) } - let controllerState = ItemListControllerState(title: "Privacy and Security", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: false) + let controllerState = ItemListControllerState(title: .text("Privacy and Security"), leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: false) let listState = ItemListNodeState(entries: privacyAndSecurityControllerEntries(state: state, privacySettings: privacySettings), style: .blocks, animateChanges: false) return (controllerState, (listState, arguments)) @@ -447,6 +458,9 @@ public func privacyAndSecurityController(account: Account, initialSettings: Sign pushControllerImpl = { [weak controller] c in (controller?.navigationController as? NavigationController)?.pushViewController(c) } + pushControllerInstantImpl = { [weak controller] c in + (controller?.navigationController as? NavigationController)?.pushViewController(c, animated: false) + } presentControllerImpl = { [weak controller] c in controller?.present(c, in: .window, with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) } diff --git a/TelegramUI/RecentGifManagedMediaId.swift b/TelegramUI/RecentGifManagedMediaId.swift new file mode 100644 index 0000000000..8474854ba6 --- /dev/null +++ b/TelegramUI/RecentGifManagedMediaId.swift @@ -0,0 +1,23 @@ +import Foundation +import TelegramCore +import Postbox + +struct RecentGifManagedMediaId: ManagedMediaId { + let id: MediaId + + init(id: MediaId) { + self.id = id + } + + var hashValue: Int { + return self.id.hashValue + } + + func isEqual(to: ManagedMediaId) -> Bool { + if let to = to as? RecentGifManagedMediaId { + return self.id == to.id + } else { + return false + } + } +} diff --git a/TelegramUI/RecentSessionsController.swift b/TelegramUI/RecentSessionsController.swift index acd7241bbb..f9a76b57ac 100644 --- a/TelegramUI/RecentSessionsController.swift +++ b/TelegramUI/RecentSessionsController.swift @@ -383,7 +383,7 @@ public func recentSessionsController(account: Account) -> ViewController { let previous = previousSessions previousSessions = sessions - let controllerState = ItemListControllerState(title: "Active Sessions", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) + let controllerState = ItemListControllerState(title: .text("Active Sessions"), leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) let listState = ItemListNodeState(entries: recentSessionsControllerEntries(state: state, sessions: sessions), style: .blocks, emptyStateItem: emptyStateItem, animateChanges: previous != nil && sessions != nil && previous!.count >= sessions!.count) return (controllerState, (listState, arguments)) diff --git a/TelegramUI/SampleBufferPool.swift b/TelegramUI/SampleBufferPool.swift new file mode 100644 index 0000000000..d87e286e35 --- /dev/null +++ b/TelegramUI/SampleBufferPool.swift @@ -0,0 +1,50 @@ +import Foundation +import UIKit +import AVFoundation +import SwiftSignalKit + +private final class SampleBufferLayerImpl: AVSampleBufferDisplayLayer { + override func action(forKey event: String) -> CAAction? { + return NSNull() + } +} + +final class SampleBufferLayer { + let layer: AVSampleBufferDisplayLayer + private let enqueue: (AVSampleBufferDisplayLayer) -> Void + + fileprivate init(layer: AVSampleBufferDisplayLayer, enqueue: @escaping (AVSampleBufferDisplayLayer) -> Void) { + self.layer = layer + self.enqueue = enqueue + } + + deinit { + self.enqueue(self.layer) + } +} + +private let pool = Atomic<[AVSampleBufferDisplayLayer]>(value: []) + +func takeSampleBufferLayer() -> SampleBufferLayer { + var layer: AVSampleBufferDisplayLayer? + let _ = pool.modify { list in + var list = list + if !list.isEmpty { + layer = list.removeLast() + } + return list + } + if layer == nil { + layer = SampleBufferLayerImpl() + } + return SampleBufferLayer(layer: layer!, enqueue: { layer in + Queue.concurrentDefaultQueue().async { + layer.flushAndRemoveImage() + let _ = pool.modify { list in + var list = list + list.append(layer) + return list + } + } + }) +} diff --git a/TelegramUI/SecretMediaPreviewController.swift b/TelegramUI/SecretMediaPreviewController.swift index edacfe9a5d..ad00f85d53 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) } - override open func dismiss() { - self.presentingViewController?.dismiss(animated: false, completion: nil) + override open func dismiss(completion: (() -> Void)? = nil) { + self.presentingViewController?.dismiss(animated: false, completion: completion) } } diff --git a/TelegramUI/SecuritySettings.swift b/TelegramUI/SecuritySettings.swift deleted file mode 100644 index 7e433cdf36..0000000000 --- a/TelegramUI/SecuritySettings.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation - -enum PasscodeSetting { - case none - case simple - case password -} - -enum AccountPassword { - case none - case password -} diff --git a/TelegramUI/SelectivePrivacySettingsController.swift b/TelegramUI/SelectivePrivacySettingsController.swift index 2e7e56f433..6866a1464f 100644 --- a/TelegramUI/SelectivePrivacySettingsController.swift +++ b/TelegramUI/SelectivePrivacySettingsController.swift @@ -418,7 +418,7 @@ func selectivePrivacySettingsController(account: Account, kind: SelectivePrivacy case .voiceCalls: title = "Voice Calls" } - let controllerState = ItemListControllerState(title: title, leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, animateChanges: false) + let controllerState = ItemListControllerState(title: .text(title), leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, animateChanges: false) let listState = ItemListNodeState(entries: selectivePrivacySettingsControllerEntries(kind: kind, state: state), style: .blocks, animateChanges: false) return (controllerState, (listState, arguments)) diff --git a/TelegramUI/SelectivePrivacySettingsPeersController.swift b/TelegramUI/SelectivePrivacySettingsPeersController.swift index f80ca047d4..26d06990ef 100644 --- a/TelegramUI/SelectivePrivacySettingsPeersController.swift +++ b/TelegramUI/SelectivePrivacySettingsPeersController.swift @@ -125,7 +125,7 @@ private enum SelectivePrivacyPeersEntry: ItemListNodeEntry { 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 + return ItemListPeerItem(account: arguments.account, peer: peer, presence: nil, text: .none, label: .none, 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) @@ -296,7 +296,7 @@ public func selectivePrivacyPeersController(account: Account, title: String, ini let previous = previousPeers previousPeers = peers - let controllerState = ItemListControllerState(title: title, leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: true) + let controllerState = ItemListControllerState(title: .text(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)) diff --git a/TelegramUI/ServiceSoundManager.swift b/TelegramUI/ServiceSoundManager.swift index 7faa9b4ba0..ff55fafa9e 100644 --- a/TelegramUI/ServiceSoundManager.swift +++ b/TelegramUI/ServiceSoundManager.swift @@ -12,23 +12,33 @@ private func loadSystemSoundFromBundle(name: String) -> SystemSoundID? { return nil } -final class ServiceSoundManager { +public final class ServiceSoundManager { private let queue = Queue() private var messageDeliverySound: SystemSoundID? + private var incomingMessageSound: SystemSoundID? init() { self.queue.async { self.messageDeliverySound = loadSystemSoundFromBundle(name: "MessageSent.caf") + self.incomingMessageSound = loadSystemSoundFromBundle(name: "notification.caf") } } - func playMessageDeliveredSound() { + public func playMessageDeliveredSound() { self.queue.async { if let messageDeliverySound = self.messageDeliverySound { AudioServicesPlaySystemSound(messageDeliverySound) } } } + + public func playIncomingMessageSound() { + self.queue.async { + if let incomingMessageSound = self.incomingMessageSound { + AudioServicesPlaySystemSound(incomingMessageSound) + } + } + } } -let serviceSoundManager = ServiceSoundManager() +public let serviceSoundManager = ServiceSoundManager() diff --git a/TelegramUI/SettingsController.swift b/TelegramUI/SettingsController.swift index e89334648d..7588a6cb25 100644 --- a/TelegramUI/SettingsController.swift +++ b/TelegramUI/SettingsController.swift @@ -3,12 +3,18 @@ import Display import SwiftSignalKit import Postbox import TelegramCore +import TelegramLegacyComponents private struct SettingsItemArguments { let account: Account let accountManager: AccountManager + let avatarAndNameInfoContext: ItemListAvatarAndNameInfoItemContext + let avatarTapAction: () -> Void + + let changeProfilePhoto: () -> Void let openPrivacyAndSecurity: () -> Void + let openDataAndStorage: () -> Void let pushController: (ViewController) -> Void let presentController: (ViewController) -> Void let updateEditingName: (ItemListAvatarAndNameInfoItemName) -> Void @@ -27,7 +33,7 @@ private enum SettingsSection: Int32 { } private enum SettingsEntry: ItemListNodeEntry { - case userInfo(Peer?, CachedPeerData?, ItemListAvatarAndNameInfoItemState) + case userInfo(Peer?, CachedPeerData?, ItemListAvatarAndNameInfoItemState, TelegramMediaImageRepresentation?) case setProfilePhoto case notificationsAndSounds @@ -87,8 +93,8 @@ private enum SettingsEntry: ItemListNodeEntry { static func ==(lhs: SettingsEntry, rhs: SettingsEntry) -> Bool { switch lhs { - case let .userInfo(lhsPeer, lhsCachedData, lhsEditingState): - if case let .userInfo(rhsPeer, rhsCachedData, rhsEditingState) = rhs { + case let .userInfo(lhsPeer, lhsCachedData, lhsEditingState, lhsUpdatingImage): + if case let .userInfo(rhsPeer, rhsCachedData, rhsEditingState, rhsUpdatingImage) = rhs { if let lhsPeer = lhsPeer, let rhsPeer = rhsPeer { if !lhsPeer.isEqual(rhsPeer) { return false @@ -106,6 +112,9 @@ private enum SettingsEntry: ItemListNodeEntry { if lhsEditingState != rhsEditingState { return false } + if lhsUpdatingImage != rhsUpdatingImage { + return false + } return true } else { return false @@ -185,12 +194,15 @@ private enum SettingsEntry: ItemListNodeEntry { func item(_ arguments: SettingsItemArguments) -> ListViewItem { switch self { - case let .userInfo(peer, cachedData, state): + case let .userInfo(peer, cachedData, state, updatingImage): return ItemListAvatarAndNameInfoItem(account: arguments.account, peer: peer, presence: TelegramUserPresence(status: .present(until: Int32.max)), cachedData: cachedData, state: state, sectionId: ItemListSectionId(self.section), style: .blocks, editingNameUpdated: { editingName in arguments.updateEditingName(editingName) - }) + }, avatarTapped: { + arguments.avatarTapAction() + }, context: arguments.avatarAndNameInfoContext, updatingImage: updatingImage) case .setProfilePhoto: return ItemListActionItem(title: "Set Profile Photo", kind: .generic, alignment: .natural, sectionId: ItemListSectionId(self.section), style: .blocks, action: { + arguments.changeProfilePhoto() }) case .notificationsAndSounds: return ItemListDisclosureItem(title: "Notifications and Sounds", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { @@ -202,7 +214,7 @@ private enum SettingsEntry: ItemListNodeEntry { }) case .dataAndStorage: return ItemListDisclosureItem(title: "Data and Storage", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { - + arguments.openDataAndStorage() }) case .stickers: return ItemListDisclosureItem(title: "Stickers", label: "", sectionId: ItemListSectionId(self.section), style: .blocks, action: { @@ -249,23 +261,31 @@ private struct SettingsEditingState: Equatable { } private struct SettingsState: Equatable { + let updatingAvatar: TelegramMediaImageRepresentation? let editingState: SettingsEditingState? let updatingName: ItemListAvatarAndNameInfoItemName? let loadingSupportPeer: Bool + func withUpdatedUpdatingAvatar(_ updatingAvatar: TelegramMediaImageRepresentation?) -> SettingsState { + return SettingsState(updatingAvatar: updatingAvatar, editingState: editingState, updatingName: self.updatingName, loadingSupportPeer: self.loadingSupportPeer) + } + func withUpdatedEditingState(_ editingState: SettingsEditingState?) -> SettingsState { - return SettingsState(editingState: editingState, updatingName: self.updatingName, loadingSupportPeer: self.loadingSupportPeer) + return SettingsState(updatingAvatar: self.updatingAvatar, editingState: editingState, updatingName: self.updatingName, loadingSupportPeer: self.loadingSupportPeer) } func withUpdatedUpdatingName(_ updatingName: ItemListAvatarAndNameInfoItemName?) -> SettingsState { - return SettingsState(editingState: self.editingState, updatingName: updatingName, loadingSupportPeer: self.loadingSupportPeer) + return SettingsState(updatingAvatar: self.updatingAvatar, editingState: self.editingState, updatingName: updatingName, loadingSupportPeer: self.loadingSupportPeer) } func withUpdatedLoadingSupportPeer(_ loadingSupportPeer: Bool) -> SettingsState { - return SettingsState(editingState: self.editingState, updatingName: self.updatingName, loadingSupportPeer: loadingSupportPeer) + return SettingsState(updatingAvatar: self.updatingAvatar, editingState: self.editingState, updatingName: self.updatingName, loadingSupportPeer: loadingSupportPeer) } static func ==(lhs: SettingsState, rhs: SettingsState) -> Bool { + if lhs.updatingAvatar != rhs.updatingAvatar { + return false + } if lhs.editingState != rhs.editingState { return false } @@ -284,7 +304,7 @@ private func settingsEntries(state: SettingsState, view: PeerView) -> [SettingsE if let peer = peerViewMainPeer(view) as? TelegramUser { let userInfoState = ItemListAvatarAndNameInfoItemState(editingName: state.editingState?.editingName, updatingName: state.updatingName) - entries.append(.userInfo(peer, view.cachedData, userInfoState)) + entries.append(.userInfo(peer, view.cachedData, userInfoState, state.updatingAvatar)) entries.append(.setProfilePhoto) entries.append(.notificationsAndSounds) @@ -310,32 +330,109 @@ private func settingsEntries(state: SettingsState, view: PeerView) -> [SettingsE } public func settingsController(account: Account, accountManager: AccountManager) -> ViewController { - let statePromise = ValuePromise(SettingsState(editingState: nil, updatingName: nil, loadingSupportPeer: false), ignoreRepeated: true) - let stateValue = Atomic(value: SettingsState(editingState: nil, updatingName: nil, loadingSupportPeer: false)) + let statePromise = ValuePromise(SettingsState(updatingAvatar: nil, editingState: nil, updatingName: nil, loadingSupportPeer: false), ignoreRepeated: true) + let stateValue = Atomic(value: SettingsState(updatingAvatar: nil, editingState: nil, updatingName: nil, loadingSupportPeer: false)) let updateState: ((SettingsState) -> SettingsState) -> Void = { f in statePromise.set(stateValue.modify { f($0) }) } var pushControllerImpl: ((ViewController) -> Void)? - var presentControllerImpl: ((ViewController) -> Void)? + var presentControllerImpl: ((ViewController, Any?) -> Void)? let actionsDisposable = DisposableSet() + let updateAvatarDisposable = MetaDisposable() + actionsDisposable.add(updateAvatarDisposable) + let updatePeerNameDisposable = MetaDisposable() actionsDisposable.add(updatePeerNameDisposable) let supportPeerDisposable = MetaDisposable() actionsDisposable.add(supportPeerDisposable) - //let privacySettings = Promise() - //privacySettings.set(.single(nil) |> then(requestAccountPrivacySettings(account: account) |> map { Optional($0) })) + let hiddenAvatarRepresentationDisposable = MetaDisposable() + actionsDisposable.add(hiddenAvatarRepresentationDisposable) - let arguments = SettingsItemArguments(account: account, accountManager: accountManager, openPrivacyAndSecurity: { + let currentAvatarMixin = Atomic(value: nil) + + var avatarGalleryTransitionArguments: ((AvatarGalleryEntry) -> GalleryTransitionArguments?)? + let avatarAndNameInfoContext = ItemListAvatarAndNameInfoItemContext() + var updateHiddenAvatarImpl: (() -> Void)? + + let arguments = SettingsItemArguments(account: account, accountManager: accountManager, avatarAndNameInfoContext: avatarAndNameInfoContext, avatarTapAction: { + var updating = false + updateState { + updating = $0.updatingAvatar != nil + return $0 + } + + if updating { + return + } + + let _ = (account.postbox.loadedPeerWithId(account.peerId) |> take(1) |> deliverOnMainQueue).start(next: { peer in + let galleryController = AvatarGalleryController(account: account, peer: peer, replaceRootController: { controller, ready in + + }) + hiddenAvatarRepresentationDisposable.set((galleryController.hiddenMedia |> deliverOnMainQueue).start(next: { entry in + avatarAndNameInfoContext.hiddenAvatarRepresentation = entry?.representations.first + updateHiddenAvatarImpl?() + })) + presentControllerImpl?(galleryController, AvatarGalleryControllerPresentationArguments(transitionArguments: { entry in + return avatarGalleryTransitionArguments?(entry) + })) + }) + }, changeProfilePhoto: { + let emptyController = LegacyEmptyController() + let navigationController = makeLegacyNavigationController(rootController: emptyController) + navigationController.setNavigationBarHidden(true, animated: false) + navigationController.navigationBar.transform = CGAffineTransform(translationX: -1000.0, y: 0.0) + + let legacyController = LegacyController(legacyController: navigationController, presentation: .custom) + + presentControllerImpl?(legacyController, nil) + + let mixin = TGMediaAvatarMenuMixin(parentController: emptyController, hasDeleteButton: false, personalPhoto: true)! + mixin.applicationInterface = legacyController.applicationInterface + let _ = currentAvatarMixin.swap(mixin) + mixin.didDismiss = { [weak legacyController] in + legacyController?.dismiss() + } + mixin.didFinishWithImage = { image in + if let image = image { + if let data = UIImageJPEGRepresentation(image, 0.6) { + let resource = LocalFileMediaResource(fileId: arc4random64()) + account.postbox.mediaBox.storeResourceData(resource.id, data: data) + let representation = TelegramMediaImageRepresentation(dimensions: CGSize(width: 640.0, height: 640.0), resource: resource) + updateState { + $0.withUpdatedUpdatingAvatar(representation) + } + updateAvatarDisposable.set((updateAccountPhoto(account: account, resource: resource) |> deliverOnMainQueue).start(next: { result in + switch result { + case .complete: + updateState { + $0.withUpdatedUpdatingAvatar(nil) + } + case .progress: + break + } + })) + } + } + } + mixin.didDismiss = { [weak legacyController] in + let _ = currentAvatarMixin.swap(nil) + legacyController?.dismiss() + } + mixin.present() + }, openPrivacyAndSecurity: { pushControllerImpl?(privacyAndSecurityController(account: account, initialSettings: .single(nil) |> then(requestAccountPrivacySettings(account: account) |> map { Optional($0) }))) + }, openDataAndStorage: { + pushControllerImpl?(dataAndStorageController(account: account)) }, pushController: { controller in pushControllerImpl?(controller) }, presentController: { controller in - presentControllerImpl?(controller) + presentControllerImpl?(controller, nil) }, updateEditingName: { editingName in updateState { state in if let _ = state.editingState { @@ -398,7 +495,7 @@ public func settingsController(account: Account, accountManager: AccountManager) let _ = logoutFromAccount(id: account.id, accountManager: accountManager).start() }) ]) - presentControllerImpl?(alertController) + presentControllerImpl?(alertController, nil) }) let peerView = account.viewTracker.peerView(account.peerId) @@ -421,7 +518,7 @@ public func settingsController(account: Account, accountManager: AccountManager) }) } - let controllerState = ItemListControllerState(title: "Settings", leftNavigationButton: nil, rightNavigationButton: rightNavigationButton) + let controllerState = ItemListControllerState(title: .text("Settings"), leftNavigationButton: nil, rightNavigationButton: rightNavigationButton) let listState = ItemListNodeState(entries: settingsEntries(state: state, view: view), style: .blocks) return (controllerState, (listState, arguments)) @@ -437,8 +534,31 @@ public func settingsController(account: Account, accountManager: AccountManager) pushControllerImpl = { [weak controller] value in (controller?.navigationController as? NavigationController)?.pushViewController(value) } - presentControllerImpl = { [weak controller] value in - controller?.present(value, in: .window, with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + presentControllerImpl = { [weak controller] value, arguments in + controller?.present(value, in: .window, with: arguments ?? ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + } + avatarGalleryTransitionArguments = { [weak controller] entry in + if let controller = controller { + var result: (ASDisplayNode, CGRect)? + controller.forEachItemNode { itemNode in + if let itemNode = itemNode as? ItemListAvatarAndNameInfoItemNode { + result = itemNode.avatarTransitionNode() + } + } + if let (node, _) = result { + return GalleryTransitionArguments(transitionNode: node, transitionContainerNode: controller.displayNode, transitionBackgroundNode: controller.displayNode) + } + } + return nil + } + updateHiddenAvatarImpl = { [weak controller] in + if let controller = controller { + controller.forEachItemNode { itemNode in + if let itemNode = itemNode as? ItemListAvatarAndNameInfoItemNode { + itemNode.updateAvatarHidden() + } + } + } } return controller } diff --git a/TelegramUI/ShareActionButtonNode.swift b/TelegramUI/ShareActionButtonNode.swift new file mode 100644 index 0000000000..1127c93b3a --- /dev/null +++ b/TelegramUI/ShareActionButtonNode.swift @@ -0,0 +1,62 @@ +import Foundation +import AsyncDisplayKit +import Display + +private let badgeBackgroundImage = generateStretchableFilledCircleImage(diameter: 22.0, color: UIColor(0x007ee5)) + +final class ShareActionButtonNode: ASButtonNode { + private let badgeLabel: ASTextNode + private let badgeBackground: ASImageNode + + var badge: String? { + didSet { + if self.badge != oldValue { + if let badge = self.badge { + self.badgeLabel.attributedText = NSAttributedString(string: badge, font: Font.regular(14.0), textColor: .white, paragraphAlignment: .center) + self.badgeLabel.isHidden = false + self.badgeBackground.isHidden = false + } else { + self.badgeLabel.attributedText = nil + self.badgeLabel.isHidden = true + self.badgeBackground.isHidden = true + } + + self.setNeedsLayout() + } + } + } + + override init() { + self.badgeLabel = ASTextNode() + self.badgeLabel.isHidden = true + self.badgeLabel.isLayerBacked = true + self.badgeLabel.displaysAsynchronously = false + + self.badgeBackground = ASImageNode() + self.badgeBackground.isHidden = true + self.badgeBackground.isLayerBacked = true + self.badgeBackground.displaysAsynchronously = false + self.badgeBackground.displayWithoutProcessing = true + + self.badgeBackground.image = badgeBackgroundImage + + super.init() + + self.addSubnode(self.badgeBackground) + self.addSubnode(self.badgeLabel) + } + + override func layout() { + super.layout() + + if !self.badgeLabel.isHidden { + let badgeSize = self.badgeLabel.measure(CGSize(width: 100.0, height: 100.0)) + + let backgroundSize = CGSize(width: max(22.0, badgeSize.width + 10.0 + 1.0), height: 22.0) + let backgroundFrame = CGRect(origin: CGPoint(x: self.titleNode.frame.maxX + 6.0, y: self.bounds.size.height - 39.0), size: backgroundSize) + + self.badgeBackground.frame = backgroundFrame + self.badgeLabel.frame = CGRect(origin: CGPoint(x: floorToScreenPixels(backgroundFrame.midX - badgeSize.width / 2.0), y: backgroundFrame.minY + 2.0), size: badgeSize) + } + } +} diff --git a/TelegramUI/ShareController.swift b/TelegramUI/ShareController.swift new file mode 100644 index 0000000000..1de851e360 --- /dev/null +++ b/TelegramUI/ShareController.swift @@ -0,0 +1,133 @@ +import Foundation +import Display +import AsyncDisplayKit +import Postbox +import TelegramCore +import SwiftSignalKit + +private func canSendMessagesToPeer(_ peer: Peer) -> Bool { + if peer is TelegramUser || peer is TelegramGroup { + return true + } else if let peer = peer as? TelegramSecretChat { + return peer.embeddedState == .active + } else if let peer = peer as? TelegramChannel { + switch peer.info { + case .broadcast: + switch peer.role { + case .creator, .editor, .moderator: + return true + case .member: + return false + } + case .group: + return true + } + } else { + return false + } +} + +public struct ShareControllerAction { + let title: String + let action: () -> Void +} + +public final class ShareController: ViewController { + private var controllerNode: ShareControllerNode { + return self.displayNode as! ShareControllerNode + } + + private var animatedIn = false + + private let account: Account + private let peers = Promise<[Peer]>() + private let peersDisposable = MetaDisposable() + + private let shareAction: ([PeerId]) -> Void + private let defaultAction: ShareControllerAction? + + public var dismissed: (() -> Void)? + + public init(account: Account, shareAction: @escaping ([PeerId]) -> Void, defaultAction: ShareControllerAction?) { + self.account = account + self.shareAction = shareAction + self.defaultAction = defaultAction + + super.init(navigationBar: NavigationBar()) + + self.navigationBar.isHidden = true + + self.peers.set(account.postbox.tailChatListView(100) |> take(1) |> map { view -> [Peer] in + var peers: [Peer] = [] + for entry in view.0.entries.reversed() { + switch entry { + case let .MessageEntry(_, message, _, _, _, renderedPeer): + if let message = message { + if let peer = message.peers[message.id.peerId] { + if canSendMessagesToPeer(peer) { + peers.append(peer) + } + } + } + default: + break + } + } + return peers + }) + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.peersDisposable.dispose() + } + + override public func loadDisplayNode() { + self.displayNode = ShareControllerNode(account: self.account) + self.controllerNode.dismiss = { [weak self] in + self?.presentingViewController?.dismiss(animated: false, completion: nil) + self?.dismissed?() + } + self.controllerNode.cancel = { [weak self] in + self?.dismiss() + } + self.controllerNode.share = { [weak self] peerIds in + self?.shareAction(peerIds) + } + self.displayNodeDidLoad() + self.peersDisposable.set((self.peers.get() |> deliverOnMainQueue).start(next: { [weak self] next in + if let strongSelf = self { + strongSelf.controllerNode.updatePeers(peers: next, defaultAction: strongSelf.defaultAction) + } + })) + self.ready.set(self.controllerNode.ready.get()) + } + + override public func loadView() { + super.loadView() + + self.statusBar.removeFromSupernode() + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if !self.animatedIn { + self.animatedIn = true + self.controllerNode.animateIn() + } + } + + override public func dismiss(completion: (() -> Void)? = nil) { + self.controllerNode.animateOut(completion: completion) + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationHeight, transition: transition) + } +} diff --git a/TelegramUI/ShareControllerNode.swift b/TelegramUI/ShareControllerNode.swift new file mode 100644 index 0000000000..15ecdbde39 --- /dev/null +++ b/TelegramUI/ShareControllerNode.swift @@ -0,0 +1,506 @@ +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) + +private let subtitleFont = Font.regular(12.0) +private let subtitleColor = UIColor(0x7b7b81) + +private let roundedBackground = generateStretchableFilledCircleImage(radius: 16.0, color: .white) +private let highlightedRoundedBackground = generateStretchableFilledCircleImage(radius: 16.0, color: highlightedBackgroundColor) + +private let halfRoundedBackground = generateImage(CGSize(width: 32.0, height: 32.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor.white.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height))) + context.fill(CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height / 2.0))) +})?.stretchableImage(withLeftCapWidth: 16, topCapHeight: 1) + +private let highlightedHalfRoundedBackground = generateImage(CGSize(width: 32.0, height: 32.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(highlightedBackgroundColor.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height))) + context.fill(CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height / 2.0))) +})?.stretchableImage(withLeftCapWidth: 16, topCapHeight: 1) + +final class ShareControllerNode: ASDisplayNode, UIScrollViewDelegate { + private let account: Account + + private var containerLayout: (ContainerViewLayout, CGFloat)? + + private let dimNode: ASDisplayNode + + private let wrappingScrollNode: ASScrollNode + private let cancelButtonNode: ASButtonNode + + private let contentContainerNode: ASDisplayNode + private let contentBackgroundNode: ASImageNode + private let contentGridNode: GridNode + private let installActionButtonNode: ShareActionButtonNode + private let installActionSeparatorNode: ASDisplayNode + private let contentTitleNode: ASTextNode + private let contentSubtitleNode: ASTextNode + private let contentSeparatorNode: ASDisplayNode + + private var activityIndicatorView: UIActivityIndicatorView? + + var dismiss: (() -> Void)? + var cancel: (() -> Void)? + var share: (([PeerId]) -> Void)? + + let ready = Promise() + private var didSetReady = false + + private var peers: [Peer]? + private var inProgress = false + private var peersUpdated = false + + private var didSetItems = false + + private var selectedPeers: [Peer] = [] + private var controllerInteraction: ShareControllerInteraction? + + private var defaultAction: ShareControllerAction? + + 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 = ASButtonNode() + self.cancelButtonNode.displaysAsynchronously = false + self.cancelButtonNode.setBackgroundImage(roundedBackground, for: .normal) + self.cancelButtonNode.setBackgroundImage(highlightedRoundedBackground, for: .highlighted) + //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 = ASImageNode() + self.contentBackgroundNode.displaysAsynchronously = false + self.contentBackgroundNode.displayWithoutProcessing = true + self.contentBackgroundNode.image = roundedBackground + //self.contentBackgroundNode.cornerRadius = 16.0 + //self.contentBackgroundNode.clipsToBounds = true + + self.contentGridNode = GridNode() + + self.installActionButtonNode = ShareActionButtonNode() + self.installActionButtonNode.displaysAsynchronously = false + self.installActionButtonNode.titleNode.displaysAsynchronously = false + self.installActionButtonNode.setBackgroundImage(halfRoundedBackground, for: .normal) + self.installActionButtonNode.setBackgroundImage(highlightedHalfRoundedBackground, for: .highlighted) + + self.contentTitleNode = ASTextNode() + + self.contentSubtitleNode = ASTextNode() + self.contentSubtitleNode.maximumNumberOfLines = 1 + self.contentSubtitleNode.isLayerBacked = true + self.contentSubtitleNode.displaysAsynchronously = false + self.contentSubtitleNode.truncationMode = .byTruncatingTail + self.contentSubtitleNode.attributedText = NSAttributedString(string: "Select chats", font: subtitleFont, textColor: subtitleColor) + + 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.controllerInteraction = ShareControllerInteraction(togglePeer: { [weak self] peer in + if let strongSelf = self { + if strongSelf.controllerInteraction!.selectedPeerIds.contains(peer.id) { + strongSelf.controllerInteraction!.selectedPeerIds.remove(peer.id) + strongSelf.selectedPeers = strongSelf.selectedPeers.filter({ $0.id != peer.id }) + } else { + strongSelf.controllerInteraction!.selectedPeerIds.insert(peer.id) + strongSelf.selectedPeers.append(peer) + } + + strongSelf.updateVisibleItemsSelection(animated: true) + if strongSelf.selectedPeers.isEmpty { + if let defaultAction = strongSelf.defaultAction { + strongSelf.installActionButtonNode.setTitle(defaultAction.title, with: Font.regular(20.0), with: UIColor(0x007ee5), for: .normal) + } + strongSelf.installActionButtonNode.badge = nil + } else { + strongSelf.installActionButtonNode.setTitle("Send", with: Font.medium(20.0), with: UIColor(0x007ee5), for: .normal) + strongSelf.installActionButtonNode.badge = "\(strongSelf.selectedPeers.count)" + } + + var subtitleText = "Select chats" + if !strongSelf.selectedPeers.isEmpty { + subtitleText = strongSelf.selectedPeers.reduce("", { string, peer in + if !string.isEmpty { + return string + ", " + peer.displayTitle + } else { + return string + peer.displayTitle + } + }) + } + strongSelf.contentSubtitleNode.attributedText = NSAttributedString(string: subtitleText, font: subtitleFont, textColor: subtitleColor) + + if let (layout, navigationBarHeight) = strongSelf.containerLayout, let _ = strongSelf.peers { + strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.4, curve: .spring)) + } + } + }) + + 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.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.contentSubtitleNode) + 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 = 64.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 peers = self.peers { + if let activityIndicatorView = self.activityIndicatorView { + activityIndicatorView.removeFromSuperview() + activityIndicatorView.stopAnimating() + } + itemCount = peers.count + if !self.didSetItems { + self.contentTitleNode.attributedText = NSAttributedString(string: "Share to", font: Font.medium(20.0), textColor: .black) + + self.didSetItems = true + animateIn = true + for i in 0 ..< peers.count { + insertItems.append(GridNodeInsertItem(index: i, item: ShareControllerPeerGridItem(account: self.account, peer: peers[i], controllerInteraction: self.controllerInteraction!), previousIndex: nil)) + } + } + } + + 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)) + + let subtitleSize = self.contentSubtitleNode.measure(CGSize(width: contentContainerFrame.size.width - 44.0 * 2.0 - 4.0 * 2.0, height: CGFloat.greatestFiniteMagnitude)) + let subtitleFrame = CGRect(origin: CGPoint(x: contentContainerFrame.minX + floor((contentContainerFrame.size.width - subtitleSize.width) / 2.0), y: self.contentBackgroundNode.frame.minY + 40.0), size: subtitleSize) + let deltaSubtitlePosition = CGPoint(x: subtitleFrame.midX - self.contentSubtitleNode.frame.midX, y: subtitleFrame.midY - self.contentSubtitleNode.frame.midY) + self.contentSubtitleNode.frame = subtitleFrame + transition.animatePosition(node: self.contentSubtitleNode, from: CGPoint(x: subtitleFrame.midX, y: subtitleFrame.midY + deltaSubtitlePosition.y)) + + 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, type: .fixed(itemSize: CGSize(width: itemWidth, height: itemWidth + 25.0))), 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) + + self.contentGridNode.layer.animateBoundsOriginYAdditive(from: -(topInset - buttonHeight), to: 0.0, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) + } + + if let _ = self.peers, self.peersUpdated { + self.dequeueUpdatePeers() + } + } + + 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 = 64.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 _ = self.peers, !inProgress { + 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) + + let subtitleSize = self.contentSubtitleNode.bounds.size + let subtitleFrame = CGRect(origin: CGPoint(x: contentFrame.minX + floor((contentFrame.size.width - subtitleSize.width) / 2.0), y: backgroundFrame.minY + 40.0), size: subtitleSize) + transition.updateFrame(node: self.contentSubtitleNode, frame: subtitleFrame) + + 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 self.selectedPeers.isEmpty { + if let defaultAction = self.defaultAction { + defaultAction.action() + } + } else { + self.share?(self.selectedPeers.map { $0.id }) + /*self.inProgress = true + if let (layout, navigationBarHeight) = self.containerLayout { + self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.4, curve: .spring)) + }*/ + } + } + + 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(completion: (() -> Void)? = nil) { + var dimCompleted = false + var offsetCompleted = false + + let internalCompletion: () -> Void = { [weak self] in + if let strongSelf = self, dimCompleted && offsetCompleted { + strongSelf.dismiss?() + } + completion?() + } + + self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in + dimCompleted = true + internalCompletion() + }) + + 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 + internalCompletion() + }) + } + + func updatePeers(peers: [Peer], defaultAction: ShareControllerAction?) { + self.defaultAction = defaultAction + + self.peers = peers + self.peersUpdated = true + if let _ = self.containerLayout { + self.dequeueUpdatePeers() + } + + self.installActionSeparatorNode.alpha = 1.0 + + if let defaultAction = defaultAction { + self.installActionButtonNode.setTitle(defaultAction.title, with: Font.regular(20.0), with: UIColor(0x007ee5), for: .normal) + } + } + + func dequeueUpdatePeers() { + if let (layout, navigationBarHeight) = self.containerLayout, let _ = peers, self.peersUpdated { + self.peersUpdated = 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 scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + let contentOffset = scrollView.contentOffset + let additionalTopHeight = max(0.0, -contentOffset.y) + + if additionalTopHeight >= 30.0 { + self.cancelButtonPressed() + } + } + + private func updateVisibleItemsSelection(animated: Bool) { + self.contentGridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ShareControllerPeerGridItemNode { + itemNode.updateSelection(animated: animated) + } + } + } +} diff --git a/TelegramUI/ShareControllerPeerGridItem.swift b/TelegramUI/ShareControllerPeerGridItem.swift new file mode 100644 index 0000000000..a975fd12f4 --- /dev/null +++ b/TelegramUI/ShareControllerPeerGridItem.swift @@ -0,0 +1,168 @@ +import Foundation +import Display +import TelegramCore +import SwiftSignalKit +import AsyncDisplayKit +import Postbox + +final class ShareControllerInteraction { + var selectedPeerIds = Set() + let togglePeer: (Peer) -> Void + + init(togglePeer: @escaping (Peer) -> Void) { + self.togglePeer = togglePeer + } +} + +private let selectionBackgroundImage = generateImage(CGSize(width: 60.0 + 4.0, height: 60.0 + 4.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor(0x007ee5).cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor.white.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: 2.0, y: 2.0), size: CGSize(width: size.width - 4.0, height: size.height - 4.0))) +}) + +final class ShareControllerPeerGridItem: GridItem { + let account: Account + let peer: Peer + let controllerInteraction: ShareControllerInteraction + + let section: GridSection? = nil + + init(account: Account, peer: Peer, controllerInteraction: ShareControllerInteraction) { + self.account = account + self.peer = peer + self.controllerInteraction = controllerInteraction + } + + func node(layout: GridNodeLayout) -> GridItemNode { + let node = ShareControllerPeerGridItemNode() + node.controllerInteraction = self.controllerInteraction + node.setup(account: self.account, peer: self.peer) + return node + } + + func update(node: GridItemNode) { + guard let node = node as? ShareControllerPeerGridItemNode else { + assertionFailure() + return + } + node.controllerInteraction = self.controllerInteraction + node.setup(account: self.account, peer: self.peer) + } +} + +private let avatarFont = Font.medium(18.0) +private let textFont = Font.regular(11.0) + +final class ShareControllerPeerGridItemNode: GridItemNode { + private var currentState: (Account, Peer)? + private let avatarSelectionNode: ASImageNode + private let avatarNodeContainer: ASDisplayNode + private let avatarNode: AvatarNode + private let textNode: ASTextNode + + var controllerInteraction: ShareControllerInteraction? + + var currentSelected = false + + override init() { + self.avatarNodeContainer = ASDisplayNode() + + self.avatarSelectionNode = ASImageNode() + self.avatarSelectionNode.image = selectionBackgroundImage + self.avatarSelectionNode.isLayerBacked = true + self.avatarSelectionNode.displayWithoutProcessing = true + self.avatarSelectionNode.displaysAsynchronously = false + self.avatarSelectionNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 60.0, height: 60.0)) + self.avatarSelectionNode.alpha = 0.0 + + self.avatarNode = AvatarNode(font: avatarFont) + self.avatarNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 60.0, height: 60.0)) + self.avatarNode.isLayerBacked = true + + self.textNode = ASTextNode() + self.textNode.isLayerBacked = true + self.textNode.displaysAsynchronously = true + self.textNode.maximumNumberOfLines = 2 + + super.init() + + self.avatarNodeContainer.addSubnode(self.avatarSelectionNode) + self.avatarNodeContainer.addSubnode(self.avatarNode) + self.addSubnode(self.avatarNodeContainer) + self.addSubnode(self.textNode) + } + + override func didLoad() { + super.didLoad() + + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + } + + func setup(account: Account, peer: Peer) { + if self.currentState == nil || self.currentState!.0 !== account || !arePeersEqual(self.currentState!.1, peer) { + let text = peer.displayTitle + self.textNode.attributedText = NSAttributedString(string: text, font: textFont, textColor: self.currentSelected ? UIColor(0x007ee5) : UIColor.black, paragraphAlignment: .center) + self.avatarNode.setPeer(account: account, peer: peer) + self.currentState = (account, peer) + self.setNeedsLayout() + } + self.updateSelection(animated: false) + } + + @objc func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + if let (_, peer) = self.currentState { + self.controllerInteraction?.togglePeer(peer) + } + } + } + + func updateSelection(animated: Bool) { + var selected = false + if let controllerInteraction = self.controllerInteraction, let (_, peer) = self.currentState { + selected = controllerInteraction.selectedPeerIds.contains(peer.id) + } + + if selected != self.currentSelected { + self.currentSelected = selected + + if let (_, peer) = self.currentState { + self.textNode.attributedText = NSAttributedString(string: peer.displayTitle, font: textFont, textColor: selected ? UIColor(0x007ee5) : UIColor.black, paragraphAlignment: .center) + } + + if selected { + self.avatarNode.transform = CATransform3DMakeScale(0.866666, 0.866666, 1.0) + self.avatarSelectionNode.alpha = 1.0 + if animated { + //self.avatarNode.layer.animateSpring(from: 1.0 as NSNumber, to: 0.866666 as NSNumber, keyPath: "transform.scale", duration: 0.5, initialVelocity: 10.0) + self.avatarNode.layer.animateScale(from: 1.0, to: 0.866666, duration: 0.2, timingFunction: kCAMediaTimingFunctionSpring) + self.avatarSelectionNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + } + } else { + self.avatarNode.transform = CATransform3DIdentity + self.avatarSelectionNode.alpha = 0.0 + if animated { + //self.avatarNode.layer.animateSpring(from: 0.866666 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.6, initialVelocity: 10.0) + self.avatarNode.layer.animateScale(from: 0.866666, to: 1.0, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) + self.avatarSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.28) + } + } + } + } + + override func layout() { + super.layout() + + let bounds = self.bounds + + self.avatarNodeContainer.frame = CGRect(origin: CGPoint(x: floor((bounds.size.width - 60.0) / 2.0), y: 4.0), size: CGSize(width: 60.0, height: 60.0)) + + self.textNode.frame = CGRect(origin: CGPoint(x: 2.0, y: 4.0 + 60.0 + 4.0), size: CGSize(width: bounds.size.width - 4.0, height: 34.0)) + } + + 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/SoftwareVideoLayerFrameManager.swift b/TelegramUI/SoftwareVideoLayerFrameManager.swift new file mode 100644 index 0000000000..3241e6d418 --- /dev/null +++ b/TelegramUI/SoftwareVideoLayerFrameManager.swift @@ -0,0 +1,114 @@ +import Foundation +import Postbox +import TelegramCore +import SwiftSignalKit +import CoreMedia + +private let applyQueue = Queue() +private let workers = ThreadPool(threadCount: 2, threadPriority: 0.09) +private var nextWorker = 0 + +final class SoftwareVideoLayerFrameManager { + private let fetchDisposable: Disposable + private var dataDisposable = MetaDisposable() + private var source: SoftwareVideoSource? + + private var baseTimestamp: Double? + private var frames: [MediaTrackFrame] = [] + private var minPts: CMTime? + private var maxPts: CMTime? + + private let account: Account + private let resource: MediaResource + private let queue: ThreadPoolQueue + private let layerHolder: SampleBufferLayer + + init(account: Account, resource: MediaResource, layerHolder: SampleBufferLayer) { + nextWorker += 1 + self.account = account + self.resource = resource + self.queue = ThreadPoolQueue(threadPool: workers) + self.layerHolder = layerHolder + self.fetchDisposable = account.postbox.mediaBox.fetchedResource(resource, tag: TelegramMediaResourceFetchTag(statsCategory: .video)).start() + } + + deinit { + self.fetchDisposable.dispose() + self.dataDisposable.dispose() + } + + func start() { + self.dataDisposable.set((self.account.postbox.mediaBox.resourceData(self.resource, option: .complete(waitUntilFetchStatus: false)) |> deliverOn(applyQueue)).start(next: { [weak self] data in + if let strongSelf = self, data.complete { + strongSelf.source = SoftwareVideoSource(path: data.path) + } + })) + } + + func tick(timestamp: Double) { + applyQueue.async { + + + if self.baseTimestamp == nil && !self.frames.isEmpty { + self.baseTimestamp = timestamp + } + + if let baseTimestamp = self.baseTimestamp { + var index = 0 + var latestFrameIndex: Int? + while index < self.frames.count { + if baseTimestamp + self.frames[index].position.seconds + self.frames[index].duration.seconds <= timestamp { + latestFrameIndex = index + } + index += 1 + } + if let latestFrameIndex = latestFrameIndex { + let frame = self.frames[latestFrameIndex] + for i in (0 ... latestFrameIndex).reversed() { + self.frames.remove(at: i) + } + if self.layerHolder.layer.status == .failed { + self.layerHolder.layer.flush() + } + self.layerHolder.layer.enqueue(frame.sampleBuffer) + } + } + + self.poll() + } + } + + private var polling = false + + private func poll() { + if self.frames.count < 3 && !self.polling { + self.polling = true + let maxPts = self.maxPts + self.queue.addTask(ThreadPoolTask { [weak self] state in + if state.cancelled { + return + } + if let strongSelf = self { + let frameAndLoop = strongSelf.source?.readFrame(maxPts: maxPts) + + applyQueue.async { + if let strongSelf = self { + strongSelf.polling = false + if let frame = frameAndLoop?.0 { + if strongSelf.minPts == nil || CMTimeCompare(strongSelf.minPts!, frame.position) < 0 { + strongSelf.minPts = frame.position + } + strongSelf.frames.append(frame) + } + if let loop = frameAndLoop?.1, loop { + strongSelf.maxPts = strongSelf.minPts + strongSelf.minPts = nil + } + strongSelf.poll() + } + } + } + }) + } + } +} diff --git a/TelegramUI/SoftwareVideoSource.swift b/TelegramUI/SoftwareVideoSource.swift new file mode 100644 index 0000000000..32812f07f0 --- /dev/null +++ b/TelegramUI/SoftwareVideoSource.swift @@ -0,0 +1,225 @@ +import Foundation +import CoreMedia +import TelegramUIPrivateModule +import SwiftSignalKit + +private func readPacketCallback(userData: UnsafeMutableRawPointer?, buffer: UnsafeMutablePointer?, bufferSize: Int32) -> Int32 { + let context = Unmanaged.fromOpaque(userData!).takeUnretainedValue() + if let fd = context.fd { + return Int32(read(fd, buffer, Int(bufferSize))) + } + return 0 +} + +private func seekCallback(userData: UnsafeMutableRawPointer?, offset: Int64, whence: Int32) -> Int64 { + let context = Unmanaged.fromOpaque(userData!).takeUnretainedValue() + if let fd = context.fd { + if (whence & AVSEEK_SIZE) != 0 { + return Int64(context.size) + } else { + lseek(fd, off_t(offset), SEEK_SET) + return offset + } + } + return 0 +} + +private final class SoftwareVideoStream { + let index: Int + let fps: CMTime + let timebase: CMTime + let duration: CMTime + let decoder: FFMpegMediaVideoFrameDecoder + let rotationAngle: Double + + init(index: Int, fps: CMTime, timebase: CMTime, duration: CMTime, decoder: FFMpegMediaVideoFrameDecoder, rotationAngle: Double) { + self.index = index + self.fps = fps + self.timebase = timebase + self.duration = duration + self.decoder = decoder + self.rotationAngle = rotationAngle + } +} + +final class SoftwareVideoSource { + private var readingError = false + private var videoStream: SoftwareVideoStream? + private var avIoContext: UnsafeMutablePointer? + private var avFormatContext: UnsafeMutablePointer? + private let path: String + fileprivate let fd: Int32? + fileprivate let size: Int32 + + init(path: String) { + let _ = FFMpegMediaFrameSourceContextHelpers.registerFFMpegGlobals + + var s = stat() + stat(path, &s) + self.size = Int32(s.st_size) + + let fd = open(path, O_RDONLY, S_IRUSR) + if fd >= 0 { + self.fd = fd + } else { + self.fd = nil + } + + self.path = path + + var avFormatContextRef = avformat_alloc_context() + guard let avFormatContext = avFormatContextRef else { + self.readingError = true + return + } + + let ioBufferSize = 64 * 1024 + let avIoBuffer = av_malloc(ioBufferSize)! + let avIoContextRef = avio_alloc_context(avIoBuffer.assumingMemoryBound(to: UInt8.self), Int32(ioBufferSize), 0, Unmanaged.passUnretained(self).toOpaque(), readPacketCallback, nil, seekCallback) + self.avIoContext = avIoContextRef + + avFormatContext.pointee.pb = self.avIoContext + + guard avformat_open_input(&avFormatContextRef, nil, nil, nil) >= 0 else { + self.readingError = true + return + } + + guard avformat_find_stream_info(avFormatContext, nil) >= 0 else { + self.readingError = true + return + } + + self.avFormatContext = avFormatContext + + var videoStream: SoftwareVideoStream? + + for streamIndex in FFMpegMediaFrameSourceContextHelpers.streamIndices(formatContext: avFormatContext, codecType: AVMEDIA_TYPE_VIDEO) { + if (avFormatContext.pointee.streams.advanced(by: streamIndex).pointee!.pointee.disposition & Int32(AV_DISPOSITION_ATTACHED_PIC)) == 0 { + + let codecPar = avFormatContext.pointee.streams.advanced(by: streamIndex).pointee!.pointee.codecpar! + + if let codec = avcodec_find_decoder(codecPar.pointee.codec_id) { + if let codecContext = avcodec_alloc_context3(codec) { + if avcodec_parameters_to_context(codecContext, avFormatContext.pointee.streams[streamIndex]!.pointee.codecpar) >= 0 { + if avcodec_open2(codecContext, codec, nil) >= 0 { + let (fps, timebase) = FFMpegMediaFrameSourceContextHelpers.streamFpsAndTimeBase(stream: avFormatContext.pointee.streams.advanced(by: streamIndex).pointee!, defaultTimeBase: CMTimeMake(1, 24)) + + let duration = CMTimeMake(avFormatContext.pointee.streams.advanced(by: streamIndex).pointee!.pointee.duration, timebase.timescale) + + var rotationAngle: Double = 0.0 + if let rotationInfo = av_dict_get(avFormatContext.pointee.streams.advanced(by: streamIndex).pointee!.pointee.metadata, "rotate", nil, 0), let value = rotationInfo.pointee.value { + if strcmp(value, "0") != 0 { + if let angle = Double(String(cString: value)) { + rotationAngle = angle * Double.pi / 180.0 + } + } + } + + videoStream = SoftwareVideoStream(index: streamIndex, fps: fps, timebase: timebase, duration: duration, decoder: FFMpegMediaVideoFrameDecoder(codecContext: codecContext), rotationAngle: rotationAngle) + break + } else { + var codecContextRef: UnsafeMutablePointer? = codecContext + avcodec_free_context(&codecContextRef) + } + } else { + var codecContextRef: UnsafeMutablePointer? = codecContext + avcodec_free_context(&codecContextRef) + } + } + } + } + } + + self.videoStream = videoStream + } + + deinit { + if let avFormatContext = self.avFormatContext { + avformat_free_context(avFormatContext) + } + if let fd = self.fd { + close(fd) + } + } + + private func readPacketInternal() -> FFMpegPacket? { + guard let avFormatContext = self.avFormatContext else { + return nil + } + + let packet = FFMpegPacket() + if av_read_frame(avFormatContext, &packet.packet) < 0 { + return nil + } else { + return packet + } + } + + func readDecodableFrame() -> (MediaTrackDecodableFrame?, Bool) { + var frames: [MediaTrackDecodableFrame] = [] + var endOfStream = false + + while !self.readingError && frames.isEmpty { + if let packet = self.readPacketInternal() { + if let videoStream = videoStream, Int(packet.packet.stream_index) == videoStream.index { + let avNoPtsRawValue: UInt64 = 0x8000000000000000 + let avNoPtsValue = Int64(bitPattern: avNoPtsRawValue) + let packetPts = packet.packet.pts == avNoPtsValue ? packet.packet.dts : packet.packet.pts + + let pts = CMTimeMake(packetPts, videoStream.timebase.timescale) + let dts = CMTimeMake(packet.packet.dts, videoStream.timebase.timescale) + + let duration: CMTime + + let frameDuration = packet.packet.duration + if frameDuration != 0 { + duration = CMTimeMake(frameDuration * videoStream.timebase.value, videoStream.timebase.timescale) + } else { + duration = videoStream.fps + } + + let frame = MediaTrackDecodableFrame(type: .video, packet: &packet.packet, pts: pts, dts: dts, duration: duration) + frames.append(frame) + } + } else { + if endOfStream { + break + } else { + if let avFormatContext = self.avFormatContext, let videoStream = self.videoStream { + endOfStream = true + av_seek_frame(avFormatContext, Int32(videoStream.index), 0, AVSEEK_FLAG_BACKWARD | AVSEEK_FLAG_FRAME) + } else { + endOfStream = true + break + } + } + } + } + + if endOfStream { + if let videoStream = self.videoStream { + videoStream.decoder.reset() + } + } + + return (frames.first, endOfStream) + } + + func readFrame(maxPts: CMTime?) -> (MediaTrackFrame?, Bool) { + if let videoStream = self.videoStream { + let (decodableFrame, loop) = self.readDecodableFrame() + if let decodableFrame = decodableFrame { + var ptsOffset: CMTime? + if let maxPts = maxPts, CMTimeCompare(decodableFrame.pts, maxPts) < 0 { + ptsOffset = maxPts + } + return (videoStream.decoder.decode(frame: decodableFrame, ptsOffset: ptsOffset), loop) + } else { + return (nil, loop) + } + } else { + return (nil, false) + } + } +} diff --git a/TelegramUI/SoftwareVideoThumbnailLayer.swift b/TelegramUI/SoftwareVideoThumbnailLayer.swift new file mode 100644 index 0000000000..a678438d34 --- /dev/null +++ b/TelegramUI/SoftwareVideoThumbnailLayer.swift @@ -0,0 +1,55 @@ +import Foundation +import UIKit +import TelegramCore +import Postbox +import SwiftSignalKit + +final class SoftwareVideoThumbnailLayer: CALayer { + var disposable: Disposable? + + var ready: (() -> Void)? { + didSet { + if self.contents != nil { + self.ready?() + } + } + } + + init(account: Account, file: TelegramMediaFile) { + super.init() + + self.backgroundColor = UIColor.black.cgColor + self.contentsGravity = "resizeAspectFill" + self.masksToBounds = true + + if let dimensions = file.dimensions { + self.disposable = (mediaGridMessageVideo(account: account, video: file) |> deliverOn(account.graphicsThreadPool)).start(next: { [weak self] transform in + + var boundingSize = dimensions.aspectFilled(CGSize(width: 93.0, height: 93.0)) + let imageSize = boundingSize + boundingSize.width = min(200.0, boundingSize.width) + + if let image = transform(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets()))?.generateImage() { + Queue.mainQueue().async { + if let strongSelf = self { + strongSelf.contents = image.cgImage + strongSelf.ready?() + } + } + } + }) + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.disposable?.dispose() + } + + override func action(forKey event: String) -> CAAction? { + return NSNull() + } +} diff --git a/TelegramUI/Sounds/notification.caf b/TelegramUI/Sounds/notification.caf new file mode 100644 index 0000000000..f3f16ae66f Binary files /dev/null and b/TelegramUI/Sounds/notification.caf differ diff --git a/TelegramUI/StickerPackGalleryController.swift b/TelegramUI/StickerPackGalleryController.swift deleted file mode 100644 index fbf287572c..0000000000 --- a/TelegramUI/StickerPackGalleryController.swift +++ /dev/null @@ -1,2 +0,0 @@ -import Foundation - diff --git a/TelegramUI/StickerPackPreviewController.swift b/TelegramUI/StickerPackPreviewController.swift index 51689e140b..e2e216962d 100644 --- a/TelegramUI/StickerPackPreviewController.swift +++ b/TelegramUI/StickerPackPreviewController.swift @@ -21,6 +21,8 @@ final class StickerPackPreviewController: ViewController { private let stickerPackInstalledDisposable = MetaDisposable() private let stickerPackInstalled = Promise() + var sendSticker: ((TelegramMediaFile) -> Void)? + init(account: Account, stickerPack: StickerPackReference) { self.account = account self.stickerPack = stickerPack @@ -49,6 +51,12 @@ final class StickerPackPreviewController: ViewController { self.controllerNode.cancel = { [weak self] in self?.dismiss() } + self.controllerNode.presentPreview = { [weak self] controller, arguments in + self?.present(controller, in: .window, with: arguments) + } + self.controllerNode.sendSticker = { [weak self] file in + self?.sendSticker?(file) + } self.displayNodeDidLoad() self.stickerPackDisposable.set((self.stickerPackContents.get() |> deliverOnMainQueue).start(next: { [weak self] next in self?.controllerNode.updateStickerPack(next) @@ -65,8 +73,8 @@ final class StickerPackPreviewController: ViewController { } } - override func dismiss() { - self.controllerNode.animateOut() + override func dismiss(completion: (() -> Void)? = nil) { + self.controllerNode.animateOut(completion: completion) } override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { diff --git a/TelegramUI/StickerPackPreviewControllerNode.swift b/TelegramUI/StickerPackPreviewControllerNode.swift index b89e5f7d2a..5562ca11aa 100644 --- a/TelegramUI/StickerPackPreviewControllerNode.swift +++ b/TelegramUI/StickerPackPreviewControllerNode.swift @@ -9,6 +9,23 @@ 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) +private let roundedBackground = generateStretchableFilledCircleImage(radius: 16.0, color: .white) +private let highlightedRoundedBackground = generateStretchableFilledCircleImage(radius: 16.0, color: highlightedBackgroundColor) + +private let halfRoundedBackground = generateImage(CGSize(width: 32.0, height: 32.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor.white.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height))) + context.fill(CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height / 2.0))) +})?.stretchableImage(withLeftCapWidth: 16, topCapHeight: 1) + +private let highlightedHalfRoundedBackground = generateImage(CGSize(width: 32.0, height: 32.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(highlightedBackgroundColor.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height))) + context.fill(CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height / 2.0))) +})?.stretchableImage(withLeftCapWidth: 16, topCapHeight: 1) + final class StickerPackPreviewControllerNode: ASDisplayNode, UIScrollViewDelegate { private let account: Account @@ -17,20 +34,24 @@ final class StickerPackPreviewControllerNode: ASDisplayNode, UIScrollViewDelegat private let dimNode: ASDisplayNode private let wrappingScrollNode: ASScrollNode - private let cancelButtonNode: HighlightTrackingButtonNode + private let cancelButtonNode: ASButtonNode private let contentContainerNode: ASDisplayNode - private let contentBackgroundNode: ASDisplayNode + private let contentBackgroundNode: ASImageNode private let contentGridNode: GridNode - private let installActionButtonNode: HighlightTrackingButtonNode + private let installActionButtonNode: ASButtonNode private let installActionSeparatorNode: ASDisplayNode private let contentTitleNode: ASTextNode private let contentSeparatorNode: ASDisplayNode private var activityIndicatorView: UIActivityIndicatorView? + private var interaction: StickerPackPreviewInteraction! + + var presentPreview: ((ViewController, Any?) -> Void)? var dismiss: (() -> Void)? var cancel: (() -> Void)? + var sendSticker: ((TelegramMediaFile) -> Void)? let ready = Promise() private var didSetReady = false @@ -40,6 +61,10 @@ final class StickerPackPreviewControllerNode: ASDisplayNode, UIScrollViewDelegat private var didSetItems = false + private var previewController: StickerPreviewController? + + private var hapticFeedback: HapticFeedback? + init(account: Account) { self.account = account @@ -51,24 +76,33 @@ final class StickerPackPreviewControllerNode: ASDisplayNode, UIScrollViewDelegat 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.cancelButtonNode = ASButtonNode() + self.cancelButtonNode.displaysAsynchronously = false + self.cancelButtonNode.setBackgroundImage(roundedBackground, for: .normal) + self.cancelButtonNode.setBackgroundImage(highlightedRoundedBackground, for: .highlighted) + //self.cancelButtonNode.cornerRadius = 16.0 + //self.cancelButtonNode.clipsToBounds = true self.contentContainerNode = ASDisplayNode() - self.contentContainerNode.cornerRadius = 16.0 - self.contentContainerNode.clipsToBounds = true + //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.contentBackgroundNode = ASImageNode() + self.contentBackgroundNode.displaysAsynchronously = false + self.contentBackgroundNode.displayWithoutProcessing = true + self.contentBackgroundNode.image = roundedBackground + //self.contentBackgroundNode.cornerRadius = 16.0 + //self.contentBackgroundNode.clipsToBounds = true self.contentGridNode = GridNode() self.installActionButtonNode = HighlightTrackingButtonNode() - + self.installActionButtonNode.displaysAsynchronously = false + self.installActionButtonNode.titleNode.displaysAsynchronously = false + self.installActionButtonNode.setBackgroundImage(halfRoundedBackground, for: .normal) + self.installActionButtonNode.setBackgroundImage(highlightedHalfRoundedBackground, for: .highlighted) + self.contentTitleNode = ASTextNode() self.contentSeparatorNode = ASDisplayNode() @@ -85,6 +119,13 @@ final class StickerPackPreviewControllerNode: ASDisplayNode, UIScrollViewDelegat return UITracingLayerView() }, didLoad: nil) + self.interaction = StickerPackPreviewInteraction(sendSticker: { [weak self] item in + if let strongSelf = self { + strongSelf.sendSticker?(item.file) + strongSelf.cancel?() + } + }) + self.backgroundColor = nil self.isOpaque = false @@ -95,7 +136,7 @@ final class StickerPackPreviewControllerNode: ASDisplayNode, UIScrollViewDelegat self.addSubnode(self.wrappingScrollNode) self.cancelButtonNode.setTitle("Cancel", with: Font.medium(20.0), with: UIColor(0x007ee5), for: .normal) - self.cancelButtonNode.backgroundColor = defaultBackgroundColor + /*self.cancelButtonNode.backgroundColor = defaultBackgroundColor self.cancelButtonNode.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { if highlighted { @@ -106,9 +147,9 @@ final class StickerPackPreviewControllerNode: ASDisplayNode, UIScrollViewDelegat }) } } - } + }*/ - self.installActionButtonNode.backgroundColor = defaultBackgroundColor + /*self.installActionButtonNode.backgroundColor = defaultBackgroundColor self.installActionButtonNode.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { if highlighted { @@ -119,7 +160,7 @@ final class StickerPackPreviewControllerNode: ASDisplayNode, UIScrollViewDelegat }) } } - } + }*/ self.wrappingScrollNode.addSubnode(self.cancelButtonNode) self.cancelButtonNode.addTarget(self, action: #selector(self.cancelButtonPressed), forControlEvents: .touchUpInside) @@ -138,6 +179,15 @@ final class StickerPackPreviewControllerNode: ASDisplayNode, UIScrollViewDelegat self.contentGridNode.presentationLayoutUpdated = { [weak self] presentationLayout, transition in self?.gridPresentationLayoutUpdated(presentationLayout, transition: transition) } + + let longTapRecognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.previewGesture(_:))) + longTapRecognizer.tapActionAtPoint = { [weak self] location in + if let strongSelf = self, let _ = strongSelf.contentGridNode.itemNodeAtPoint(location) as? StickerPackPreviewGridItemNode { + return .waitForHold(timeout: 0.2, acceptTap: true) + } + return .fail + } + self.contentGridNode.view.addGestureRecognizer(longTapRecognizer) } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { @@ -192,7 +242,7 @@ final class StickerPackPreviewControllerNode: ASDisplayNode, UIScrollViewDelegat 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)) + insertItems.append(GridNodeInsertItem(index: i, item: StickerPackPreviewGridItem(account: self.account, stickerItem: items[i] as! StickerPackItem, interaction: self.interaction), previousIndex: nil)) } } } @@ -228,7 +278,7 @@ final class StickerPackPreviewControllerNode: ASDisplayNode, UIScrollViewDelegat 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 }) + 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, type: .fixed(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 { @@ -246,8 +296,8 @@ final class StickerPackPreviewControllerNode: ASDisplayNode, UIScrollViewDelegat 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) + + self.contentGridNode.layer.animateBoundsOriginYAdditive(from: -(topInset - buttonHeight), to: 0.0, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) } if let _ = self.stickerPack, self.stickerPackUpdated { @@ -342,19 +392,20 @@ final class StickerPackPreviewControllerNode: ASDisplayNode, UIScrollViewDelegat self.layer.animateBoundsOriginYAdditive(from: -offset, to: 0.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) } - func animateOut() { + func animateOut(completion: (() -> Void)? = nil) { var dimCompleted = false var offsetCompleted = false - let completion: () -> Void = { [weak self] in + let internalCompletion: () -> Void = { [weak self] in if let strongSelf = self, dimCompleted && offsetCompleted { strongSelf.dismiss?() } + completion?() } self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in dimCompleted = true - completion() + internalCompletion() }) let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY @@ -362,7 +413,7 @@ final class StickerPackPreviewControllerNode: ASDisplayNode, UIScrollViewDelegat 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() + internalCompletion() }) } @@ -432,4 +483,64 @@ final class StickerPackPreviewControllerNode: ASDisplayNode, UIScrollViewDelegat self.cancelButtonPressed() } } + + @objc func previewGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { + switch recognizer.state { + case .began: + if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation, case .hold = gesture { + if let itemNode = self.contentGridNode.itemNodeAtPoint(location) as? StickerPackPreviewGridItemNode { + self.updatePreviewingItem(item: itemNode.stickerPackItem, animated: true) + } + } + case .ended, .cancelled: + self.updatePreviewingItem(item: nil, animated: true) + case .changed: + if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation, case .hold = gesture, let itemNode = self.contentGridNode.itemNodeAtPoint(location) as? StickerPackPreviewGridItemNode { + self.updatePreviewingItem(item: itemNode.stickerPackItem, animated: true) + } + default: + break + } + } + + private func updatePreviewingItem(item: StickerPackItem?, animated: Bool) { + if self.interaction.previewedItem != item { + self.interaction.previewedItem = item + + self.contentGridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? StickerPackPreviewGridItemNode { + itemNode.updatePreviewing(animated: animated) + } + } + + if let item = item { + if let previewController = self.previewController { + self.hapticFeedback?.tap() + self.hapticFeedback?.prepareTap() + previewController.updateItem(item) + } else { + self.hapticFeedback = HapticFeedback() + self.hapticFeedback?.prepareTap() + let previewController = StickerPreviewController(account: self.account, item: item) + self.previewController = previewController + self.presentPreview?(previewController, StickerPreviewControllerPresentationArguments(transitionNode: { [weak self] item in + if let strongSelf = self { + var result: ASDisplayNode? + strongSelf.contentGridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? StickerPackPreviewGridItemNode, itemNode.stickerPackItem == item { + result = itemNode.transitionNode() + } + } + return result + } + return nil + })) + } + } else if let previewController = self.previewController { + self.hapticFeedback = nil + previewController.dismiss() + self.previewController = nil + } + } + } } diff --git a/TelegramUI/StickerPackPreviewGridItem.swift b/TelegramUI/StickerPackPreviewGridItem.swift index e671300ee2..dc7931bd19 100644 --- a/TelegramUI/StickerPackPreviewGridItem.swift +++ b/TelegramUI/StickerPackPreviewGridItem.swift @@ -5,20 +5,32 @@ import SwiftSignalKit import AsyncDisplayKit import Postbox +final class StickerPackPreviewInteraction { + var previewedItem: StickerPackItem? + + let sendSticker: (StickerPackItem) -> Void + + init(sendSticker: @escaping (StickerPackItem) -> Void) { + self.sendSticker = sendSticker + } +} + final class StickerPackPreviewGridItem: GridItem { let account: Account let stickerItem: StickerPackItem + let interaction: StickerPackPreviewInteraction let section: GridSection? = nil - init(account: Account, stickerItem: StickerPackItem) { + init(account: Account, stickerItem: StickerPackItem, interaction: StickerPackPreviewInteraction) { self.account = account self.stickerItem = stickerItem + self.interaction = interaction } func node(layout: GridNodeLayout) -> GridItemNode { let node = StickerPackPreviewGridItemNode() - node.setup(account: self.account, stickerItem: self.stickerItem) + node.setup(account: self.account, stickerItem: self.stickerItem, interaction: self.interaction) return node } @@ -27,25 +39,33 @@ final class StickerPackPreviewGridItem: GridItem { assertionFailure() return } - node.setup(account: self.account, stickerItem: self.stickerItem) + node.setup(account: self.account, stickerItem: self.stickerItem, interaction: self.interaction) } } +private let textFont = Font.regular(20.0) + final class StickerPackPreviewGridItemNode: GridItemNode { private var currentState: (Account, StickerPackItem, CGSize)? private let imageNode: TransformImageNode private let textNode: ASTextNode + private var currentIsPreviewing = false + private let stickerFetchedDisposable = MetaDisposable() - var interfaceInteraction: ChatControllerInteraction? - var inputNodeInteraction: ChatMediaInputNodeInteraction? + var interaction: StickerPackPreviewInteraction? + var selected: (() -> Void)? + var stickerPackItem: StickerPackItem? { + return self.currentState?.1 + } + override init() { self.imageNode = TransformImageNode() - //self.imageNode.alphaTransitionOnFirstUpdate = true self.imageNode.isLayerBacked = true + //self.imageNode.alphaTransitionOnFirstUpdate = true self.textNode = ASTextNode() self.textNode.isLayerBacked = true @@ -67,7 +87,9 @@ final class StickerPackPreviewGridItemNode: GridItemNode { self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.imageNodeTap(_:)))) } - func setup(account: Account, stickerItem: StickerPackItem) { + func setup(account: Account, stickerItem: StickerPackItem, interaction: StickerPackPreviewInteraction) { + self.interaction = interaction + if self.currentState == nil || self.currentState!.0 !== account || self.currentState!.1 != stickerItem { var text = "" for attribute in stickerItem.file.attributes { @@ -76,7 +98,7 @@ final class StickerPackPreviewGridItemNode: GridItemNode { break } } - self.textNode.attributedText = NSAttributedString(string: text, font: Font.regular(20.0), textColor: .black, paragraphAlignment: .right) + self.textNode.attributedText = NSAttributedString(string: text, font: textFont, 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()) @@ -107,25 +129,40 @@ final class StickerPackPreviewGridItemNode: GridItemNode { } } - /*func transitionNode(id: MessageId, media: Media) -> ASDisplayNode? { - if self.messageId == id { - return self.imageNode - } else { - return nil - } - }*/ + func transitionNode() -> ASDisplayNode? { + return self + } @objc func imageNodeTap(_ recognizer: UITapGestureRecognizer) { - if let interfaceInteraction = self.interfaceInteraction, let (_, item, _) = self.currentState, case .ended = recognizer.state { - interfaceInteraction.sendSticker(item.file) + if let interaction = self.interaction, let (_, item, _) = self.currentState, case .ended = recognizer.state { + interaction.sendSticker(item) } - /*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) } + + func updatePreviewing(animated: Bool) { + var isPreviewing = false + if let (_, item, _) = self.currentState, let interaction = self.interaction { + isPreviewing = interaction.previewedItem == item + } + if self.currentIsPreviewing != isPreviewing { + self.currentIsPreviewing = isPreviewing + + if isPreviewing { + self.layer.sublayerTransform = CATransform3DMakeScale(0.8, 0.8, 1.0) + if animated { + self.layer.animateSpring(from: 1.0 as NSNumber, to: 0.8 as NSNumber, keyPath: "sublayerTransform.scale", duration: 0.4) + } + } else { + self.layer.sublayerTransform = CATransform3DIdentity + if animated { + self.layer.animateSpring(from: 0.8 as NSNumber, to: 1.0 as NSNumber, keyPath: "sublayerTransform.scale", duration: 0.5) + } + } + } + } } diff --git a/TelegramUI/StickerPreviewController.swift b/TelegramUI/StickerPreviewController.swift new file mode 100644 index 0000000000..f8c71c4f00 --- /dev/null +++ b/TelegramUI/StickerPreviewController.swift @@ -0,0 +1,76 @@ +import Foundation +import Display +import AsyncDisplayKit +import Postbox +import TelegramCore +import SwiftSignalKit + +final class StickerPreviewControllerPresentationArguments { + let transitionNode: (StickerPackItem) -> ASDisplayNode? + + init(transitionNode: @escaping (StickerPackItem) -> ASDisplayNode?) { + self.transitionNode = transitionNode + } +} + +final class StickerPreviewController: ViewController { + private var controllerNode: StickerPreviewControllerNode { + return self.displayNode as! StickerPreviewControllerNode + } + + private var animatedIn = false + + private let account: Account + private var item: StickerPackItem + + init(account: Account, item: StickerPackItem) { + self.account = account + self.item = item + + super.init(navigationBar: NavigationBar()) + + self.navigationBar.isHidden = true + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadDisplayNode() { + self.displayNode = StickerPreviewControllerNode(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.controllerNode.updateItem(self.item) + //self.ready.set(self.controllerNode.ready.get()) + self.ready.set(.single(true)) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if !self.animatedIn { + self.animatedIn = true + self.controllerNode.animateIn(sourceNode: (self.presentationArguments as? StickerPreviewControllerPresentationArguments)?.transitionNode(self.item)) + } + } + + override func dismiss(completion: (() -> Void)? = nil) { + self.controllerNode.animateOut(targetNode: (self.presentationArguments as? StickerPreviewControllerPresentationArguments)?.transitionNode(self.item), completion: completion) + } + + override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationHeight, transition: transition) + } + + func updateItem(_ item: StickerPackItem) { + self.item = item + self.controllerNode.updateItem(item) + } +} diff --git a/TelegramUI/StickerPreviewControllerNode.swift b/TelegramUI/StickerPreviewControllerNode.swift new file mode 100644 index 0000000000..28a9f9e679 --- /dev/null +++ b/TelegramUI/StickerPreviewControllerNode.swift @@ -0,0 +1,147 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore + +final class StickerPreviewControllerNode: ASDisplayNode, UIScrollViewDelegate { + private let account: Account + + private let dimNode: ASDisplayNode + + private var textNode: ASTextNode + private var imageNode: TransformImageNode + private var containerLayout: (ContainerViewLayout, CGFloat)? + + private var item: StickerPackItem? + + var dismiss: (() -> Void)? + var cancel: (() -> Void)? + + init(account: Account) { + self.account = account + + self.dimNode = ASDisplayNode() + self.dimNode.backgroundColor = UIColor(white: 1.0, alpha: 0.6) + + self.textNode = ASTextNode() + self.imageNode = TransformImageNode() + self.imageNode.addSubnode(self.textNode) + + super.init(viewBlock: { + return UITracingLayerView() + }, didLoad: nil) + + self.addSubnode(self.dimNode) + self.addSubnode(self.imageNode) + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + self.containerLayout = (layout, navigationBarHeight) + + transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + + let boundingSize = CGSize(width: 180.0, height: 180.0) + + if let item = self.item, let dimensitons = item.file.dimensions { + let textSpacing: CGFloat = 10.0 + let textSize = self.textNode.measure(CGSize(width: 100.0, height: 100.0)) + + let imageSize = dimensitons.aspectFitted(boundingSize) + self.imageNode.asyncLayout()(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets()))() + let imageFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - imageSize.width) / 2.0), y: (layout.size.height - imageSize.height - textSpacing - textSize.height) / 4.0), size: imageSize) + self.imageNode.frame = imageFrame + + self.textNode.frame = CGRect(origin: CGPoint(x: floor((imageFrame.size.width - textSize.width) / 2.0), y: -textSize.height - textSpacing), size: textSize) + + /*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)*/ + } + } + + @objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.cancel?() + } + } + + func animateIn(sourceNode: ASDisplayNode?) { + self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + + if let sourceNode = sourceNode { + let location = sourceNode.view.convert(CGPoint(x: sourceNode.bounds.midX, y: sourceNode.bounds.midY), to: self.view) + self.imageNode.layer.animateSpring(from: NSValue(cgPoint: location), to: NSValue(cgPoint: self.imageNode.layer.position), keyPath: "position", duration: 0.6, damping: 100.0) + self.imageNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.6, damping: 100.0) + } + + self.imageNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + } + + func animateOut(targetNode: ASDisplayNode?, completion: (() -> Void)? = nil) { + var dimCompleted = false + var itemCompleted = false + + let internalCompletion: () -> Void = { [weak self] in + if let strongSelf = self, dimCompleted && itemCompleted { + strongSelf.dismiss?() + } + completion?() + } + + self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in + dimCompleted = true + internalCompletion() + }) + + if let targetNode = targetNode { + let location = targetNode.view.convert(CGPoint(x: targetNode.bounds.midX, y: targetNode.bounds.midY), to: self.view) + self.imageNode.layer.animateScale(from: 1.0, to: 0.2, duration: 0.2, removeOnCompletion: false) + self.imageNode.layer.animatePosition(from: self.imageNode.layer.position, to: location, duration: 0.25, removeOnCompletion: false, completion: { _ in + itemCompleted = true + internalCompletion() + }) + self.imageNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false) + } else { + self.imageNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.35, removeOnCompletion: false, completion: { _ in + itemCompleted = true + internalCompletion() + }) + } + } + + func updateItem(_ item: StickerPackItem) { + var animateIn = false + if let _ = self.item { + animateIn = true + let previousImageNode = self.imageNode + previousImageNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + previousImageNode.layer.animateSpring(from: 1.0 as NSNumber, to: 0.4 as NSNumber, keyPath: "transform.scale", duration: 0.4, damping: 88.0, removeOnCompletion: false, completion: { [weak previousImageNode] _ in + previousImageNode?.removeFromSupernode() + }) + + self.imageNode = TransformImageNode() + self.textNode = ASTextNode() + self.imageNode.addSubnode(self.textNode) + self.addSubnode(self.imageNode) + } + + self.item = item + + for case let .Sticker(text, _) in item.file.attributes { + self.textNode.attributedText = NSAttributedString(string: text, font: Font.regular(32.0), textColor: .black) + break + } + self.imageNode.setSignal(account: account, signal: chatMessageSticker(account: account, file: item.file, small: false)) + + if let (layout, navigationBarHeight) = self.containerLayout { + self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) + } + + if animateIn { + self.imageNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) + self.imageNode.layer.animateSpring(from: 0.5 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.7, damping: 88.0) + } + } +} diff --git a/TelegramUI/StickerResources.swift b/TelegramUI/StickerResources.swift index eabc977f9a..7cbb9d4028 100644 --- a/TelegramUI/StickerResources.swift +++ b/TelegramUI/StickerResources.swift @@ -34,8 +34,7 @@ private func imageFromAJpeg(data: Data) -> (UIImage, UIImage)? { return nil } -private func chatMessageStickerDatas(account: Account, file: TelegramMediaFile, small: Bool) -> Signal<(Data?, Data?, Bool), NoError> { - //let maybeFetched = account.postbox.mediaBox.resourceData(file.resource, complete: true) +private func chatMessageStickerDatas(account: Account, file: TelegramMediaFile, small: Bool, fetched: Bool) -> Signal<(Data?, Data?, Bool), NoError> { let maybeFetched = account.postbox.mediaBox.cachedResourceRepresentation(file.resource, representation: CachedStickerAJpegRepresentation(size: small ? CGSize(width: 160.0, height: 160.0) : nil), complete: false) return maybeFetched |> take(1) |> mapToSignal { maybeData in @@ -44,21 +43,39 @@ private func chatMessageStickerDatas(account: Account, file: TelegramMediaFile, return .single((nil, loadedData, true)) } else { - //let fullSizeData = account.postbox.mediaBox.resourceData(file.resource, complete: true) - let fullSizeData = account.postbox.mediaBox.cachedResourceRepresentation(file.resource, representation: CachedStickerAJpegRepresentation(size: small ? CGSize(width: 160.0, height: 160.0) : nil), complete: false) |> map { next in return (next.size == 0 ? nil : try? Data(contentsOf: URL(fileURLWithPath: next.path), options: .mappedIfSafe), next.complete) } - return fullSizeData |> map { (data, complete) -> (Data?, Data?, Bool) in - return (nil, data, complete) + if fetched { + return Signal { subscriber in + let fetch = account.postbox.mediaBox.fetchedResource(file.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .generic)).start() + let disposable = (fullSizeData |> map { (data, complete) -> (Data?, Data?, Bool) in + return (nil, data, complete) + }).start(next: { next in + subscriber.putNext(next) + }, error: { error in + subscriber.putError(error) + }, completed: { + subscriber.putCompletion() + }) + + return ActionDisposable { + fetch.dispose() + disposable.dispose() + } + } + } else { + return fullSizeData |> map { (data, complete) -> (Data?, Data?, Bool) in + return (nil, data, complete) + } } } } } -func chatMessageSticker(account: Account, file: TelegramMediaFile, small: Bool) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { - let signal = chatMessageStickerDatas(account: account, file: file, small: small) +func chatMessageSticker(account: Account, file: TelegramMediaFile, small: Bool, fetched: Bool = false) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { + let signal = chatMessageStickerDatas(account: account, file: file, small: small, fetched: fetched) return signal |> map { (thumbnailData, fullSizeData, fullSizeComplete) in return { arguments in diff --git a/TelegramUI/StorageUsageController.swift b/TelegramUI/StorageUsageController.swift new file mode 100644 index 0000000000..27a1694998 --- /dev/null +++ b/TelegramUI/StorageUsageController.swift @@ -0,0 +1,377 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private final class StorageUsageControllerArguments { + let account: Account + let updateKeepMedia: () -> Void + let openPeerMedia: (PeerId) -> Void + + init(account: Account, updateKeepMedia: @escaping () -> Void, openPeerMedia: @escaping (PeerId) -> Void) { + self.account = account + self.updateKeepMedia = updateKeepMedia + self.openPeerMedia = openPeerMedia + } +} + +private enum StorageUsageSection: Int32 { + case keepMedia + case peers +} + +private enum StorageUsageEntry: ItemListNodeEntry { + case keepMedia(String, String) + case keepMediaInfo(String) + + case collecting(String) + case peersHeader(String) + case peer(Int32, Peer, String) + + var section: ItemListSectionId { + switch self { + case .keepMedia, .keepMediaInfo: + return StorageUsageSection.keepMedia.rawValue + case .collecting, .peersHeader, .peer: + return StorageUsageSection.peers.rawValue + } + } + + var stableId: Int32 { + switch self { + case .keepMedia: + return 0 + case .keepMediaInfo: + return 1 + case .collecting: + return 2 + case .peersHeader: + return 3 + case let .peer(index, _, _): + return 4 + index + } + } + + static func ==(lhs: StorageUsageEntry, rhs: StorageUsageEntry) -> Bool { + switch lhs { + case let .keepMedia(text, value): + if case .keepMedia(text, value) = rhs { + return true + } else { + return false + } + case let .keepMediaInfo(text): + if case .keepMediaInfo(text) = rhs { + return true + } else { + return false + } + case let .collecting(text): + if case .collecting(text) = rhs { + return true + } else { + return false + } + case let .peersHeader(text): + if case .peersHeader(text) = rhs { + return true + } else { + return false + } + case let .peer(lhsIndex, lhsPeer, lhsValue): + if case let .peer(rhsIndex, rhsPeer, rhsValue) = rhs { + if lhsIndex != rhsIndex { + return false + } + if !arePeersEqual(lhsPeer, rhsPeer) { + return false + } + if lhsValue != rhsValue { + return false + } + return true + } else { + return false + } + } + } + + static func <(lhs: StorageUsageEntry, rhs: StorageUsageEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(_ arguments: StorageUsageControllerArguments) -> ListViewItem { + switch self { + case let .keepMedia(text, value): + return ItemListDisclosureItem(title: text, label: value, sectionId: self.section, style: .blocks, action: { + arguments.updateKeepMedia() + }) + case let .keepMediaInfo(text): + return ItemListTextItem(text: .markdown(text), sectionId: self.section) + case let .collecting(text): + return ItemListActivityTextItem(displayActivity: true, text: NSAttributedString(string: text, textColor: UIColor(0x6d6d72)), sectionId: self.section) + case let .peersHeader(text): + return ItemListSectionHeaderItem(text: text, sectionId: self.section) + case let .peer(_, peer, value): + return ItemListPeerItem(account: arguments.account, peer: peer, presence: nil, text: .none, label: .disclosure(value), editing: ItemListPeerItemEditing(editable: false, editing: false, revealed: false), switchValue: nil, enabled: true, sectionId: self.section, action: { + arguments.openPeerMedia(peer.id) + }, setPeerIdWithRevealedOptions: { previousId, id in + + }, removePeer: { _ in + + }) + } + } +} + +private func stringForKeepMediaTimeout(_ timeout: Int32) -> String { + if timeout <= 7 * 24 * 60 * 60 { + return "1 week" + } else if timeout <= 1 * 31 * 24 * 60 * 60 { + return "1 month" + } else { + return "Forever" + } +} + +private func storageUsageControllerEntries(cacheSettings: CacheStorageSettings, cacheStats: CacheUsageStatsResult?) -> [StorageUsageEntry] { + var entries: [StorageUsageEntry] = [] + + entries.append(.keepMedia("Keep Media", stringForKeepMediaTimeout(cacheSettings.defaultCacheStorageTimeout))) + entries.append(.keepMediaInfo("Photos, videos and other files from cloud chats that you have **not accessed** during this period will be removed from this device to save disk space.\n\nAll media will stay in the Telegram cloud and can be re-downloaded if you need it again.")) + + var addedHeader = false + + if let cacheStats = cacheStats, case let .result(stats) = cacheStats { + var statsByPeerId: [(PeerId, Int64)] = [] + for (peerId, categories) in stats.media { + var combinedSize: Int64 = 0 + for (_, media) in categories { + for (_, size) in media { + combinedSize += size + } + } + statsByPeerId.append((peerId, combinedSize)) + } + var index: Int32 = 0 + for (peerId, size) in statsByPeerId.sorted(by: { $0.1 > $1.1 }) { + if size >= 32 * 1024 { + if let peer = stats.peers[peerId] { + if !addedHeader { + addedHeader = true + entries.append(.peersHeader("CHATS")) + } + entries.append(.peer(index, peer, dataSizeString(Int(size)))) + index += 1 + } + } + } + } else { + entries.append(.collecting("Calculating current cache size...")) + } + + return entries +} + +private func stringForCategory(_ category: PeerCacheUsageCategory) -> String { + switch category { + case .image: + return "Photos" + case .video: + return "Videos" + case .audio: + return "Audio" + case .file: + return "Documents" + } +} + +func storageUsageController(account: Account) -> ViewController { + let cacheSettingsPromise = Promise() + cacheSettingsPromise.set(account.postbox.preferencesView(keys: [PreferencesKeys.cacheStorageSettings]) + |> map { view -> CacheStorageSettings in + let cacheSettings: CacheStorageSettings + if let value = view.values[PreferencesKeys.cacheStorageSettings] as? CacheStorageSettings { + cacheSettings = value + } else { + cacheSettings = CacheStorageSettings.defaultSettings + } + + return cacheSettings + }) + + var presentControllerImpl: ((ViewController) -> Void)? + + let statsPromise = Promise() + statsPromise.set(.single(nil) |> then(collectCacheUsageStats(account: account) |> map { Optional($0) })) + + let actionDisposables = DisposableSet() + + let clearDisposable = MetaDisposable() + actionDisposables.add(clearDisposable) + + let arguments = StorageUsageControllerArguments(account: account, updateKeepMedia: { + let controller = ActionSheetController() + let dismissAction: () -> Void = { [weak controller] in + controller?.dismissAnimated() + } + let timeoutAction: (Int32) -> Void = { timeout in + let _ = updateCacheStorageSettingsInteractively(postbox: account.postbox, { current in + return current.withUpdatedDefaultCacheStorageTimeout(timeout) + }).start() + } + controller.setItemGroups([ + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: "1 week", action: { + dismissAction() + timeoutAction(7 * 24 * 60 * 60) + }), + ActionSheetButtonItem(title: "1 month", action: { + dismissAction() + timeoutAction(1 * 31 * 24 * 60 * 60) + }), + ActionSheetButtonItem(title: "Forever", action: { + dismissAction() + timeoutAction(Int32.max) + }) + ]), + ActionSheetItemGroup(items: [ActionSheetButtonItem(title: "Cancel", action: { dismissAction() })]) + ]) + presentControllerImpl?(controller) + }, openPeerMedia: { peerId in + let _ = (statsPromise.get() |> take(1) |> deliverOnMainQueue).start(next: { [weak statsPromise] result in + if let result = result, case let .result(stats) = result { + if let categories = stats.media[peerId] { + let controller = ActionSheetController() + let dismissAction: () -> Void = { [weak controller] in + controller?.dismissAnimated() + } + + var sizeIndex: [PeerCacheUsageCategory: (Bool, Int64)] = [:] + + var itemIndex = 0 + + let updateTotalSize: () -> Void = { [weak controller] in + controller?.updateItem(groupIndex: 0, itemIndex: itemIndex, { item in + let title: String + let filteredSize = sizeIndex.values.reduce(0, { $0 + ($1.0 ? $1.1 : 0) }) + + if filteredSize == 0 { + title = "Clear" + } else { + title = "Clear (\(dataSizeString(Int(filteredSize))))" + } + + if let item = item as? ActionSheetButtonItem { + return ActionSheetButtonItem(title: title, color: filteredSize != 0 ? .accent : .disabled, enabled: filteredSize != 0, action: item.action) + } + return item + }) + } + + let toggleCheck: (PeerCacheUsageCategory, Int) -> Void = { [weak controller] category, itemIndex in + if let (value, size) = sizeIndex[category] { + sizeIndex[category] = (!value, size) + } + controller?.updateItem(groupIndex: 0, itemIndex: itemIndex, { item in + if let item = item as? ActionSheetCheckboxItem { + return ActionSheetCheckboxItem(title: item.title, label: item.label, value: !item.value, action: item.action) + } + return item + }) + updateTotalSize() + } + var items: [ActionSheetItem] = [] + + let validCategories: [PeerCacheUsageCategory] = [.image, .video, .audio, .file] + + var totalSize: Int64 = 0 + + for categoryId in validCategories { + if let media = categories[categoryId] { + var categorySize: Int64 = 0 + for (_, size) in media { + categorySize += size + } + sizeIndex[categoryId] = (true, categorySize) + totalSize += categorySize + let index = itemIndex + items.append(ActionSheetCheckboxItem(title: stringForCategory(categoryId), label: dataSizeString(Int(categorySize)), value: true, action: { value in + toggleCheck(categoryId, index) + })) + itemIndex += 1 + } + } + + if !items.isEmpty { + items.append(ActionSheetButtonItem(title: "Clear (\(dataSizeString(Int(totalSize))))", action: { + if let statsPromise = statsPromise { + var clearCategories = sizeIndex.keys.filter({ sizeIndex[$0]!.0 }) + //var clearSize: Int64 = 0 + + var clearMediaIds = Set() + + var media = stats.media + if var categories = media[peerId] { + for category in clearCategories { + if let contents = categories[category] { + for (mediaId, size) in contents { + clearMediaIds.insert(mediaId) + //clearSize += size + } + } + categories.removeValue(forKey: category) + } + + media[peerId] = categories + } + + var clearResourceIds = Set() + for id in clearMediaIds { + if let ids = stats.mediaResourceIds[id] { + for resourceId in ids { + clearResourceIds.insert(WrappedMediaResourceId(resourceId)) + } + } + } + + statsPromise.set(.single(.result(CacheUsageStats(media: media, mediaResourceIds: stats.mediaResourceIds, peers: stats.peers)))) + + clearDisposable.set(clearCachedMediaResources(account: account, mediaResourceIds: clearResourceIds).start()) + } + + dismissAction() + })) + + controller.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ActionSheetButtonItem(title: "Cancel", action: { dismissAction() })]) + ]) + presentControllerImpl?(controller) + } + } + } + }) + }) + + let signal = combineLatest(cacheSettingsPromise.get(), statsPromise.get()) |> deliverOnMainQueue + |> map { cacheSettings, cacheStats -> (ItemListControllerState, (ItemListNodeState, StorageUsageEntry.ItemGenerationArguments)) in + + let controllerState = ItemListControllerState(title: .text("Storage Usage"), leftNavigationButton: nil, rightNavigationButton: nil, animateChanges: false) + let listState = ItemListNodeState(entries: storageUsageControllerEntries(cacheSettings: cacheSettings, cacheStats: cacheStats), style: .blocks, emptyStateItem: nil, animateChanges: false) + + return (controllerState, (listState, arguments)) + } |> afterDisposed { + actionDisposables.dispose() + } + + let controller = ItemListController(signal) + controller.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + + presentControllerImpl = { [weak controller] c in + controller?.present(c, in: .window, with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + } + + return controller +} diff --git a/TelegramUI/TapLongTapOrDoubleTapGestureRecognizer.swift b/TelegramUI/TapLongTapOrDoubleTapGestureRecognizer.swift index a28a3d1610..843f625c01 100644 --- a/TelegramUI/TapLongTapOrDoubleTapGestureRecognizer.swift +++ b/TelegramUI/TapLongTapOrDoubleTapGestureRecognizer.swift @@ -33,7 +33,8 @@ enum TapLongTapOrDoubleTapGesture { enum TapLongTapOrDoubleTapGestureRecognizerAction { case waitForDoubleTap case waitForSingleTap - case waitForHold + case waitForHold(timeout: Double, acceptTap: Bool) + case fail } final class TapLongTapOrDoubleTapGestureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate { @@ -45,6 +46,8 @@ final class TapLongTapOrDoubleTapGestureRecognizer: UIGestureRecognizer, UIGestu var tapActionAtPoint: ((CGPoint) -> TapLongTapOrDoubleTapGestureRecognizerAction)? + var hapticFeedback: HapticFeedback? + override init(target: Any?, action: Selector?) { super.init(target: target, action: action) @@ -63,6 +66,7 @@ final class TapLongTapOrDoubleTapGestureRecognizer: UIGestureRecognizer, UIGestu self.timer = nil self.touchLocationAndTimestamp = nil self.tapCount = 0 + self.hapticFeedback = nil super.reset() } @@ -93,6 +97,7 @@ final class TapLongTapOrDoubleTapGestureRecognizer: UIGestureRecognizer, UIGestu self.timer?.invalidate() self.timer = nil if let (location, _) = self.touchLocationAndTimestamp { + self.hapticFeedback?.tap() self.lastRecognizedGestureAndLocation = (.hold, location) } else { self.lastRecognizedGestureAndLocation = nil @@ -101,6 +106,8 @@ final class TapLongTapOrDoubleTapGestureRecognizer: UIGestureRecognizer, UIGestu } override func touchesBegan(_ touches: Set, with event: UIEvent) { + self.lastRecognizedGestureAndLocation = nil + super.touchesBegan(touches, with: event) if let touch = touches.first { @@ -130,11 +137,15 @@ final class TapLongTapOrDoubleTapGestureRecognizer: UIGestureRecognizer, UIGestu let timer = Timer(timeInterval: 0.3, target: TapLongTapOrDoubleTapGestureRecognizerTimerTarget(target: self), selector: #selector(TapLongTapOrDoubleTapGestureRecognizerTimerTarget.longTapEvent), userInfo: nil, repeats: false) self.timer = timer RunLoop.main.add(timer, forMode: RunLoopMode.commonModes) - case .waitForHold: - self.lastRecognizedGestureAndLocation = (.hold, touchLocationAndTimestamp.0) - let timer = Timer(timeInterval: 0.1, target: TapLongTapOrDoubleTapGestureRecognizerTimerTarget(target: self), selector: #selector(TapLongTapOrDoubleTapGestureRecognizerTimerTarget.holdEvent), userInfo: nil, repeats: false) + case let .waitForHold(timeout, _): + //self.lastRecognizedGestureAndLocation = (.hold, touchLocationAndTimestamp.0) + self.hapticFeedback = HapticFeedback() + self.hapticFeedback?.prepareTap() + let timer = Timer(timeInterval: timeout, target: TapLongTapOrDoubleTapGestureRecognizerTimerTarget(target: self), selector: #selector(TapLongTapOrDoubleTapGestureRecognizerTimerTarget.holdEvent), userInfo: nil, repeats: false) self.timer = timer RunLoop.main.add(timer, forMode: RunLoopMode.commonModes) + case .fail: + self.state = .failed } } } @@ -143,7 +154,14 @@ final class TapLongTapOrDoubleTapGestureRecognizer: UIGestureRecognizer, UIGestu override func touchesMoved(_ touches: Set, with event: UIEvent) { super.touchesMoved(touches, with: event) + guard let touch = touches.first else { + return + } + if let (gesture, _) = self.lastRecognizedGestureAndLocation, case .hold = gesture { + let location = touch.location(in: self.view) + self.lastRecognizedGestureAndLocation = (.hold, location) + self.state = .changed return } @@ -184,8 +202,16 @@ final class TapLongTapOrDoubleTapGestureRecognizer: UIGestureRecognizer, UIGestu let timer = Timer(timeInterval: 0.2, target: TapLongTapOrDoubleTapGestureRecognizerTimerTarget(target: self), selector: #selector(TapLongTapOrDoubleTapGestureRecognizerTimerTarget.tapEvent), userInfo: nil, repeats: false) self.timer = timer RunLoop.main.add(timer, forMode: RunLoopMode.commonModes) - case .waitForHold: - break + case let .waitForHold(_, acceptTap): + if let (touchLocation, _) = self.touchLocationAndTimestamp, acceptTap { + if self.state != .began { + self.lastRecognizedGestureAndLocation = (.tap, touchLocation) + self.state = .began + } + } + self.state = .ended + case .fail: + self.state = .failed } } } diff --git a/TelegramUI/TelegramAccountAuxiliaryMethods.swift b/TelegramUI/TelegramAccountAuxiliaryMethods.swift index 02e8a60ed7..730328c5ca 100644 --- a/TelegramUI/TelegramAccountAuxiliaryMethods.swift +++ b/TelegramUI/TelegramAccountAuxiliaryMethods.swift @@ -10,7 +10,7 @@ public let telegramAccountAuxiliaryMethods = AccountAuxiliaryMethods(updatePeerC } else { return interfaceState } -}, fetchResource: { account, resource, range in +}, fetchResource: { account, resource, range, _ in if let resource = resource as? VideoLibraryMediaResource { return fetchVideoLibraryMediaResource(resource: resource) } else if let resource = resource as? LocalFileVideoMediaResource { diff --git a/TelegramUI/TelegramApplicationContext.swift b/TelegramUI/TelegramApplicationContext.swift index 29ec6c8298..6ceb2dda37 100644 --- a/TelegramUI/TelegramApplicationContext.swift +++ b/TelegramUI/TelegramApplicationContext.swift @@ -11,11 +11,13 @@ public final class TelegramApplicationContext { let mediaManager = MediaManager() public let applicationInForeground: Signal + public let applicationIsActive: Signal - public init(openUrl: @escaping (String) -> Void, getTopWindow: @escaping () -> UIWindow?, displayNotification: @escaping (String) -> Void, applicationInForeground: Signal) { + public init(openUrl: @escaping (String) -> Void, getTopWindow: @escaping () -> UIWindow?, displayNotification: @escaping (String) -> Void, applicationInForeground: Signal, applicationIsActive: Signal) { self.openUrl = openUrl self.getTopWindow = getTopWindow self.displayNotification = displayNotification self.applicationInForeground = applicationInForeground + self.applicationIsActive = applicationIsActive } } diff --git a/TelegramUI/TransformOutgoingMessageMedia.swift b/TelegramUI/TransformOutgoingMessageMedia.swift index b6317db0cc..c9c92a2578 100644 --- a/TelegramUI/TransformOutgoingMessageMedia.swift +++ b/TelegramUI/TransformOutgoingMessageMedia.swift @@ -8,7 +8,7 @@ public func transformOutgoingMessageMedia(postbox: Postbox, network: Network, me switch media { case let file as TelegramMediaFile: let signal = Signal { subscriber in - let fetch = postbox.mediaBox.fetchedResource(file.resource).start() + let fetch = postbox.mediaBox.fetchedResource(file.resource, tag: TelegramMediaResourceFetchTag(statsCategory: statsCategoryForFileWithAttributes(file.attributes))).start() let data = postbox.mediaBox.resourceData(file.resource, option: .complete(waitUntilFetchStatus: true)).start(next: { next in subscriber.putNext(next) if next.complete { @@ -64,7 +64,7 @@ public func transformOutgoingMessageMedia(postbox: Postbox, network: Network, me case let image as TelegramMediaImage: if let representation = largestImageRepresentation(image.representations) { let signal = Signal { subscriber in - let fetch = postbox.mediaBox.fetchedResource(representation.resource).start() + let fetch = postbox.mediaBox.fetchedResource(representation.resource, tag: TelegramMediaResourceFetchTag(statsCategory: .image)).start() let data = postbox.mediaBox.resourceData(representation.resource, option: .complete(waitUntilFetchStatus: true)).start(next: { next in subscriber.putNext(next) if next.complete { diff --git a/TelegramUI/TwoStepVerificationPasswordEntryController.swift b/TelegramUI/TwoStepVerificationPasswordEntryController.swift index 09bfe4dd33..7da201586e 100644 --- a/TelegramUI/TwoStepVerificationPasswordEntryController.swift +++ b/TelegramUI/TwoStepVerificationPasswordEntryController.swift @@ -413,7 +413,7 @@ func twoStepVerificationPasswordEntryController(account: Account, mode: TwoStepV }) } - let controllerState = ItemListControllerState(title: "Password", leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, animateChanges: false) + let controllerState = ItemListControllerState(title: .text("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)) diff --git a/TelegramUI/TwoStepVerificationResetController.swift b/TelegramUI/TwoStepVerificationResetController.swift index 06e87f981d..af6cf4fcb8 100644 --- a/TelegramUI/TwoStepVerificationResetController.swift +++ b/TelegramUI/TwoStepVerificationResetController.swift @@ -212,7 +212,7 @@ func twoStepVerificationResetController(account: Account, emailPattern: String, }) } - let controllerState = ItemListControllerState(title: "E-Mail Code", leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, animateChanges: false) + let controllerState = ItemListControllerState(title: .text("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)) diff --git a/TelegramUI/TwoStepVerificationUnlockController.swift b/TelegramUI/TwoStepVerificationUnlockController.swift index b65a5f740d..10ba9aad4f 100644 --- a/TelegramUI/TwoStepVerificationUnlockController.swift +++ b/TelegramUI/TwoStepVerificationUnlockController.swift @@ -506,7 +506,7 @@ func twoStepVerificationUnlockSettingsController(account: Account, mode: TwoStep } } - let controllerState = ItemListControllerState(title: title, leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, animateChanges: false) + let controllerState = ItemListControllerState(title: .text(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)) diff --git a/TelegramUI/UserInfoController.swift b/TelegramUI/UserInfoController.swift index 33ceaaf03b..2a6e18ed32 100644 --- a/TelegramUI/UserInfoController.swift +++ b/TelegramUI/UserInfoController.swift @@ -6,7 +6,9 @@ import TelegramCore private final class UserInfoControllerArguments { let account: Account + let avatarAndNameInfoContext: ItemListAvatarAndNameInfoItemContext let updateEditingName: (ItemListAvatarAndNameInfoItemName) -> Void + let tapAvatarAction: () -> Void let openChat: () -> Void let changeNotificationMuteSettings: () -> Void let openSharedMedia: () -> Void @@ -15,9 +17,11 @@ private final class UserInfoControllerArguments { let deleteContact: () -> Void let displayUsernameContextMenu: (String) -> 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, displayUsernameContextMenu: @escaping (String) -> Void) { + init(account: Account, avatarAndNameInfoContext: ItemListAvatarAndNameInfoItemContext, updateEditingName: @escaping (ItemListAvatarAndNameInfoItemName) -> Void, tapAvatarAction: @escaping () -> Void, openChat: @escaping () -> Void, changeNotificationMuteSettings: @escaping () -> Void, openSharedMedia: @escaping () -> Void, openGroupsInCommon: @escaping () -> Void, updatePeerBlocked: @escaping (Bool) -> Void, deleteContact: @escaping () -> Void, displayUsernameContextMenu: @escaping (String) -> Void) { self.account = account + self.avatarAndNameInfoContext = avatarAndNameInfoContext self.updateEditingName = updateEditingName + self.tapAvatarAction = tapAvatarAction self.openChat = openChat self.changeNotificationMuteSettings = changeNotificationMuteSettings self.openSharedMedia = openSharedMedia @@ -239,7 +243,9 @@ private enum UserInfoEntry: ItemListNodeEntry { case let .info(peer, presence, cachedData, state): return ItemListAvatarAndNameInfoItem(account: arguments.account, peer: peer, presence: presence, cachedData: cachedData, state: state, sectionId: self.section, style: .plain, editingNameUpdated: { editingName in arguments.updateEditingName(editingName) - }) + }, avatarTapped: { + arguments.tapAvatarAction() + }, context: arguments.avatarAndNameInfoContext) case let .about(text): return ItemListTextWithLabelItem(label: "about", text: text, multiline: true, sectionId: self.section, action: nil) case let .phoneNumber(_, value): @@ -440,16 +446,12 @@ public func userInfoController(account: Account, peerId: PeerId) -> ViewControll } var pushControllerImpl: ((ViewController) -> Void)? - var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments) -> Void)? + var presentControllerImpl: ((ViewController, Any?) -> Void)? var openChatImpl: (() -> Void)? var displayUsernameContextMenuImpl: ((String) -> Void)? let actionsDisposable = DisposableSet() - if peerId.namespace == Namespaces.Peer.CloudChannel { - actionsDisposable.add(account.viewTracker.updatedCachedChannelParticipants(peerId, forceImmediateUpdate: true).start()) - } - let updatePeerNameDisposable = MetaDisposable() actionsDisposable.add(updatePeerNameDisposable) @@ -459,7 +461,14 @@ public func userInfoController(account: Account, peerId: PeerId) -> ViewControll let changeMuteSettingsDisposable = MetaDisposable() actionsDisposable.add(changeMuteSettingsDisposable) - let arguments = UserInfoControllerArguments(account: account, updateEditingName: { editingName in + let hiddenAvatarRepresentationDisposable = MetaDisposable() + actionsDisposable.add(hiddenAvatarRepresentationDisposable) + + var avatarGalleryTransitionArguments: ((AvatarGalleryEntry) -> GalleryTransitionArguments?)? + let avatarAndNameInfoContext = ItemListAvatarAndNameInfoItemContext() + var updateHiddenAvatarImpl: (() -> Void)? + + let arguments = UserInfoControllerArguments(account: account, avatarAndNameInfoContext: avatarAndNameInfoContext, updateEditingName: { editingName in updateState { state in if let _ = state.editingState { return state.withUpdatedEditingState(UserInfoEditingState(editingName: editingName)) @@ -467,6 +476,23 @@ public func userInfoController(account: Account, peerId: PeerId) -> ViewControll return state } } + }, tapAvatarAction: { + let _ = (account.postbox.loadedPeerWithId(peerId) |> take(1) |> deliverOnMainQueue).start(next: { peer in + if peer.profileImageRepresentations.isEmpty { + return + } + + let galleryController = AvatarGalleryController(account: account, peer: peer, replaceRootController: { controller, ready in + + }) + hiddenAvatarRepresentationDisposable.set((galleryController.hiddenMedia |> deliverOnMainQueue).start(next: { entry in + avatarAndNameInfoContext.hiddenAvatarRepresentation = entry?.representations.first + updateHiddenAvatarImpl?() + })) + presentControllerImpl?(galleryController, AvatarGalleryControllerPresentationArguments(transitionArguments: { entry in + return avatarGalleryTransitionArguments?(entry) + })) + }) }, openChat: { openChatImpl?() }, changeNotificationMuteSettings: { @@ -585,7 +611,7 @@ public func userInfoController(account: Account, peerId: PeerId) -> ViewControll }) } - let controllerState = ItemListControllerState(title: "Info", leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton) + let controllerState = ItemListControllerState(title: .text("Info"), leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton) 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)) @@ -635,5 +661,28 @@ public func userInfoController(account: Account, peerId: PeerId) -> ViewControll } } } + avatarGalleryTransitionArguments = { [weak controller] entry in + if let controller = controller { + var result: (ASDisplayNode, CGRect)? + controller.forEachItemNode { itemNode in + if let itemNode = itemNode as? ItemListAvatarAndNameInfoItemNode { + result = itemNode.avatarTransitionNode() + } + } + if let (node, _) = result { + return GalleryTransitionArguments(transitionNode: node, transitionContainerNode: controller.displayNode, transitionBackgroundNode: controller.displayNode) + } + } + return nil + } + updateHiddenAvatarImpl = { [weak controller] in + if let controller = controller { + controller.forEachItemNode { itemNode in + if let itemNode = itemNode as? ItemListAvatarAndNameInfoItemNode { + itemNode.updateAvatarHidden() + } + } + } + } return controller } diff --git a/TelegramUI/UsernameSetupController.swift b/TelegramUI/UsernameSetupController.swift index 8f2688872b..b3191cf5d5 100644 --- a/TelegramUI/UsernameSetupController.swift +++ b/TelegramUI/UsernameSetupController.swift @@ -282,7 +282,7 @@ public func usernameSetupController(account: Account) -> ViewController { dismissImpl?() }) - let controllerState = ItemListControllerState(title: "Username", leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, animateChanges: false) + let controllerState = ItemListControllerState(title: .text("Username"), leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, animateChanges: false) let listState = ItemListNodeState(entries: usernameSetupControllerEntries(view: view, state: state), style: .blocks, animateChanges: false) return (controllerState, (listState, arguments)) diff --git a/TelegramUI/VideoPlayerProxy.swift b/TelegramUI/VideoPlayerProxy.swift index fbf287572c..d02745a7ec 100644 --- a/TelegramUI/VideoPlayerProxy.swift +++ b/TelegramUI/VideoPlayerProxy.swift @@ -1,2 +1,117 @@ import Foundation +import SwiftSignalKit +import AVFoundation +private final class VideoPlayerProxyContext { + private let queue: Queue + + var updateVideoInHierarchy: ((Bool) -> Void)? + + var node: MediaPlayerNode? { + didSet { + self.node?.takeFrameAndQueue = self.takeFrameAndQueue + self.node?.state = state + self.updateVideoInHierarchy?(node?.videoInHierarchy ?? false) + self.node?.updateVideoInHierarchy = { [weak self] value in + self?.updateVideoInHierarchy?(value) + } + } + } + + var takeFrameAndQueue: (Queue, () -> MediaTrackFrameResult)? { + didSet { + self.node?.takeFrameAndQueue = self.takeFrameAndQueue + } + } + + var state: (timebase: CMTimebase, requestFrames: Bool, rotationAngle: Double)? { + didSet { + self.node?.state = self.state + } + } + + init(queue: Queue) { + self.queue = queue + } + + deinit { + assert(self.queue.isCurrent()) + } +} + +final class VideoPlayerProxy { + var takeFrameAndQueue: (Queue, () -> MediaTrackFrameResult)? { + didSet { + let updatedTakeFrameAndQueue = self.takeFrameAndQueue + self.withContext { context in + context?.takeFrameAndQueue = updatedTakeFrameAndQueue + } + } + } + + var state: (timebase: CMTimebase, requestFrames: Bool, rotationAngle: Double)? { + didSet { + let updatedState = self.state + self.withContext { context in + context?.state = updatedState + } + } + } + + private let queue: Queue + private let contextQueue = Queue.mainQueue() + private var contextRef: Unmanaged? + + var visibility: Bool = false + var visibilityUpdated: ((Bool) -> Void)? + + init(queue: Queue) { + self.queue = queue + + self.contextQueue.async { + let context = VideoPlayerProxyContext(queue: self.contextQueue) + context.updateVideoInHierarchy = { [weak self] value in + queue.async { + if let strongSelf = self { + if strongSelf.visibility != value { + strongSelf.visibility = value + strongSelf.visibilityUpdated?(value) + } + } + } + } + self.contextRef = Unmanaged.passRetained(context) + } + } + + deinit { + let contextRef = self.contextRef + self.contextQueue.async { + if let contextRef = contextRef { + let context = contextRef.takeUnretainedValue() + context.state = nil + contextRef.release() + } + } + } + + private func withContext(_ f: @escaping (VideoPlayerProxyContext?) -> Void) { + self.contextQueue.async { + if let contextRef = self.contextRef { + let context = contextRef.takeUnretainedValue() + f(context) + } else { + f(nil) + } + } + } + + func attachNodeAndRelease(_ nodeRef: Unmanaged) { + self.withContext { context in + if let context = context { + context.node = nodeRef.takeUnretainedValue() + } + nodeRef.release() + } + } +} diff --git a/TelegramUI/VoiceCallDataSavingController.swift b/TelegramUI/VoiceCallDataSavingController.swift new file mode 100644 index 0000000000..3c7955b149 --- /dev/null +++ b/TelegramUI/VoiceCallDataSavingController.swift @@ -0,0 +1,150 @@ +import Foundation +import Display +import SwiftSignalKit +import Postbox +import TelegramCore + +private final class VoiceCallDataSavingControllerArguments { + let updateSelection: (VoiceCallDataSaving) -> Void + + init(updateSelection: @escaping (VoiceCallDataSaving) -> Void) { + self.updateSelection = updateSelection + } +} + +private enum VoiceCallDataSavingSection: Int32 { + case dataSaving +} + +private enum VoiceCallDataSavingEntry: ItemListNodeEntry { + case never(String, Bool) + case cellular(String, Bool) + case always(String, Bool) + case info(String) + + var section: ItemListSectionId { + return VoiceCallDataSavingSection.dataSaving.rawValue + } + + var stableId: Int32 { + switch self { + case .never: + return 0 + case .cellular: + return 1 + case .always: + return 2 + case .info: + return 3 + } + } + + static func ==(lhs: VoiceCallDataSavingEntry, rhs: VoiceCallDataSavingEntry) -> Bool { + switch lhs { + case let .never(text, value): + if case .never(text, value) = rhs { + return true + } else { + return false + } + case let .cellular(text, value): + if case .cellular(text, value) = rhs { + return true + } else { + return false + } + case let .always(text, value): + if case .always(text, value) = rhs { + return true + } else { + return false + } + case let .info(text): + if case .info(text) = rhs { + return true + } else { + return false + } + } + } + + static func <(lhs: VoiceCallDataSavingEntry, rhs: VoiceCallDataSavingEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(_ arguments: VoiceCallDataSavingControllerArguments) -> ListViewItem { + switch self { + case let .never(text, value): + return ItemListCheckboxItem(title: text, checked: value, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.updateSelection(.never) + }) + case let .cellular(text, value): + return ItemListCheckboxItem(title: text, checked: value, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.updateSelection(.cellular) + }) + case let .always(text, value): + return ItemListCheckboxItem(title: text, checked: value, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.updateSelection(.always) + }) + case let .info(text): + return ItemListTextItem(text: .plain(text), sectionId: self.section) + } + } +} + +private func stringForDataSavingOption(_ option: VoiceCallDataSaving) -> String { + switch option { + case .never: + return "Never" + case .cellular: + return "On Mobile Network" + case .always: + return "Always" + } +} + +private func voiceCallDataSavingControllerEntries(settings: VoiceCallSettings) -> [VoiceCallDataSavingEntry] { + var entries: [VoiceCallDataSavingEntry] = [] + + entries.append(.never(stringForDataSavingOption(.never), settings.dataSaving == .never)) + entries.append(.cellular(stringForDataSavingOption(.cellular), settings.dataSaving == .cellular)) + entries.append(.always(stringForDataSavingOption(.always), settings.dataSaving == .always)) + entries.append(.info("Using less data may improve your experience on bad networks, but will slightly decrease audio quality.")) + + return entries +} + +func voiceCallDataSavingController(account: Account) -> ViewController { + let voiceCallSettingsPromise = Promise() + voiceCallSettingsPromise.set(account.postbox.preferencesView(keys: [ApplicationSpecificPreferencesKeys.voiceCallSettings]) + |> map { view -> VoiceCallSettings in + let voiceCallSettings: VoiceCallSettings + if let value = view.values[ApplicationSpecificPreferencesKeys.voiceCallSettings] as? VoiceCallSettings { + voiceCallSettings = value + } else { + voiceCallSettings = VoiceCallSettings.defaultSettings + } + + return voiceCallSettings + }) + + let arguments = VoiceCallDataSavingControllerArguments(updateSelection: { option in + let _ = updateVoiceCallSettingsSettingsInteractively(postbox: account.postbox, { current in + return current.withUpdatedDataSaving(option) + }).start() + }) + + let signal = voiceCallSettingsPromise.get() |> deliverOnMainQueue + |> map { data -> (ItemListControllerState, (ItemListNodeState, VoiceCallDataSavingEntry.ItemGenerationArguments)) in + + let controllerState = ItemListControllerState(title: .text("Use Less Data"), leftNavigationButton: nil, rightNavigationButton: nil, animateChanges: false) + let listState = ItemListNodeState(entries: voiceCallDataSavingControllerEntries(settings: data), style: .blocks, emptyStateItem: nil, animateChanges: false) + + return (controllerState, (listState, arguments)) + } + + let controller = ItemListController(signal) + controller.navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) + + return controller +} diff --git a/TelegramUI/VoiceCallSettings.swift b/TelegramUI/VoiceCallSettings.swift new file mode 100644 index 0000000000..3e57b02724 --- /dev/null +++ b/TelegramUI/VoiceCallSettings.swift @@ -0,0 +1,59 @@ +import Foundation +import Postbox +import SwiftSignalKit + +public enum VoiceCallDataSaving: Int32 { + case never + case cellular + case always +} + +public struct VoiceCallSettings: PreferencesEntry, Equatable { + public let dataSaving: VoiceCallDataSaving + + public static var defaultSettings: VoiceCallSettings { + return VoiceCallSettings(dataSaving: .never) + } + + init(dataSaving: VoiceCallDataSaving) { + self.dataSaving = dataSaving + } + + public init(decoder: Decoder) { + self.dataSaving = VoiceCallDataSaving(rawValue: (decoder.decodeInt32ForKey("ds") as Int32))! + } + + public func encode(_ encoder: Encoder) { + encoder.encodeInt32(self.dataSaving.rawValue, forKey: "ds") + } + + public func isEqual(to: PreferencesEntry) -> Bool { + if let to = to as? VoiceCallSettings { + return self == to + } else { + return false + } + } + + public static func ==(lhs: VoiceCallSettings, rhs: VoiceCallSettings) -> Bool { + return lhs.dataSaving == rhs.dataSaving + } + + func withUpdatedDataSaving(_ dataSaving: VoiceCallDataSaving) -> VoiceCallSettings { + return VoiceCallSettings(dataSaving: dataSaving) + } +} + +func updateVoiceCallSettingsSettingsInteractively(postbox: Postbox, _ f: @escaping (VoiceCallSettings) -> VoiceCallSettings) -> Signal { + return postbox.modify { modifier -> Void in + modifier.updatePreferencesEntry(key: ApplicationSpecificPreferencesKeys.voiceCallSettings, { entry in + let currentSettings: VoiceCallSettings + if let entry = entry as? VoiceCallSettings { + currentSettings = entry + } else { + currentSettings = VoiceCallSettings.defaultSettings + } + return f(currentSettings) + }) + } +} diff --git a/TelegramUI/ZoomableContentGalleryItemNode.swift b/TelegramUI/ZoomableContentGalleryItemNode.swift index f7de6113b9..53b98918e2 100644 --- a/TelegramUI/ZoomableContentGalleryItemNode.swift +++ b/TelegramUI/ZoomableContentGalleryItemNode.swift @@ -33,16 +33,44 @@ class ZoomableContentGalleryItemNode: GalleryItemNode, UIScrollViewDelegate { self.scrollView.scrollsToTop = false self.scrollView.delaysContentTouches = false - let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.contentTap(_:))) + let tapRecognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.contentTap(_:))) + tapRecognizer.tapActionAtPoint = { _ in + return .waitForDoubleTap + } self.scrollView.addGestureRecognizer(tapRecognizer) self.view.addSubview(self.scrollView) } - @objc func contentTap(_ recognizer: UITapGestureRecognizer) { + @objc func contentTap(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { if recognizer.state == .ended { - self.toggleControlsVisibility() + if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation { + switch gesture { + case .tap: + self.toggleControlsVisibility() + case .doubleTap: + if let contentView = self.zoomableContent?.1.view, self.scrollView.zoomScale.isLessThanOrEqualTo(self.scrollView.minimumZoomScale) { + let pointInView = self.scrollView.convert(location, to: contentView) + + let newZoomScale = self.scrollView.maximumZoomScale + let scrollViewSize = self.scrollView.bounds.size + + let w = scrollViewSize.width / newZoomScale + let h = scrollViewSize.height / newZoomScale + let x = pointInView.x - (w / 2.0) + let y = pointInView.y - (h / 2.0) + + let rectToZoomTo = CGRect(x: x, y: y, width: w, height: h) + + self.scrollView.zoom(to: rectToZoomTo, animated: true) + } else { + self.scrollView.setZoomScale(self.scrollView.minimumZoomScale, animated: true) + } + default: + break + } + } } }