From d55e3da7b338d29824dc96111887e3f0d89ce7f3 Mon Sep 17 00:00:00 2001 From: Peter Date: Tue, 18 Apr 2017 19:53:47 +0300 Subject: [PATCH] no message --- .../ChatListLock_LockedBottom@2x.png | Bin 0 -> 106 bytes .../LockLockedBottom.imageset/Contents.json | 21 + .../ChatListLock_LockedTop@2x.png | Bin 0 -> 177 bytes .../LockLockedTop.imageset/Contents.json | 21 + .../ChatListLock_UnlockedBottom@2x.png | Bin 0 -> 95 bytes .../LockUnlockedBottom.imageset/Contents.json | 21 + .../ChatListLock_UnlockedTop@2x.png | Bin 0 -> 152 bytes .../LockUnlockedTop.imageset/Contents.json | 21 + .../ActionsWhiteIcon@2x.png | Bin 0 -> 210 bytes .../ActionsWhiteIcon@3x.png | Bin 0 -> 628 bytes .../Contents.json | 22 + .../Message/ShareIcon.imageset/Contents.json | 22 + .../ConversationChannelInlineShareIcon@2x.png | Bin 0 -> 423 bytes .../ConversationChannelInlineShareIcon@3x.png | Bin 0 -> 622 bytes TelegramUI.xcodeproj/project.pbxproj | 213 +++++- .../ArhivedStickerPacksController.swift | 2 +- .../AuthorizationSequenceController.swift | 14 +- .../AutomaticMediaDownloadSettings.swift | 159 ++++ TelegramUI/AvatarGalleryController.swift | 305 ++++++++ TelegramUI/AvatarNode.swift | 12 +- TelegramUI/BlockedPeersController.swift | 4 +- .../ChangePhoneNumberCodeController.swift | 2 +- TelegramUI/ChannelAdminsController.swift | 4 +- TelegramUI/ChannelBlacklistController.swift | 4 +- TelegramUI/ChannelInfoController.swift | 181 ++++- TelegramUI/ChannelMembersController.swift | 4 +- TelegramUI/ChannelVisibilityController.swift | 5 +- TelegramUI/ChatButtonKeyboardInputNode.swift | 2 + TelegramUI/ChatController.swift | 87 ++- TelegramUI/ChatControllerInteraction.swift | 15 +- TelegramUI/ChatControllerNode.swift | 2 +- TelegramUI/ChatDocumentGalleryItem.swift | 19 +- TelegramUI/ChatHistoryGridNode.swift | 4 +- TelegramUI/ChatHistoryListNode.swift | 11 +- TelegramUI/ChatImageGalleryItem.swift | 38 +- .../ChatInterfaceStateContextMenus.swift | 11 + .../ChatItemGalleryFooterContentNode.swift | 382 ++++++++++ TelegramUI/ChatItemGalleryItemNode.swift | 5 + TelegramUI/ChatListController.swift | 26 + TelegramUI/ChatListItem.swift | 66 +- TelegramUI/ChatListTitleLockView.swift | 88 +++ TelegramUI/ChatMediaActionSheetRollItem.swift | 3 + TelegramUI/ChatMediaInputGifPane.swift | 64 ++ TelegramUI/ChatMediaInputNode.swift | 325 ++++++++- TelegramUI/ChatMediaInputPanelEntries.swift | 50 +- TelegramUI/ChatMediaInputRecentGifsItem.swift | 118 +++ ...ChatMediaInputRecentStickerPacksItem.swift | 4 +- .../ChatMediaInputStickerGridItem.swift | 43 +- TelegramUI/ChatMediaInputStickerPane.swift | 24 + TelegramUI/ChatMediaInputTrendingItem.swift | 111 +++ TelegramUI/ChatMediaInputTrendingPane.swift | 22 + TelegramUI/ChatMessageBubbleContentNode.swift | 2 + TelegramUI/ChatMessageBubbleImages.swift | 13 + TelegramUI/ChatMessageBubbleItemNode.swift | 95 ++- .../ChatMessageInstantVideoItemNode.swift | 269 +++++++ .../ChatMessageInteractiveMediaNode.swift | 106 ++- TelegramUI/ChatMessageItem.swift | 19 +- TelegramUI/ChatMessageItemView.swift | 9 +- .../ChatMessageMediaBubbleContentNode.swift | 10 +- TelegramUI/ChatMessageNotificationItem.swift | 202 +++++ TelegramUI/ChatMessageSelectionNode.swift | 3 + .../ChatMessageWebpageBubbleContentNode.swift | 4 +- ...ChatSecretAutoremoveTimerActionSheet.swift | 3 + TelegramUI/ChatTitleView.swift | 4 + TelegramUI/ChatVideoGalleryItem.swift | 25 +- .../ContactMultiselectionController.swift | 4 +- .../ContactMultiselectionControllerNode.swift | 3 +- TelegramUI/ContactSelectionController.swift | 6 +- .../ContactSelectionControllerNode.swift | 3 +- .../ConvertToSupergroupController.swift | 2 +- TelegramUI/CreateChannelController.swift | 3 +- TelegramUI/CreateGroupController.swift | 5 +- .../DataAndStorageSettingsController.swift | 488 ++++++++++++- TelegramUI/DebugAccountsController.swift | 2 +- TelegramUI/DebugController.swift | 2 +- TelegramUI/DeclareEncodables.swift | 3 + TelegramUI/FFMpegAudioFrameDecoder.swift | 6 +- TelegramUI/FFMpegMediaFrameSource.swift | 19 +- .../FFMpegMediaFrameSourceContext.swift | 81 ++- ...FFMpegMediaFrameSourceContextHelpers.swift | 2 +- ...pegMediaPassthroughVideoFrameDecoder.swift | 11 +- TelegramUI/FFMpegMediaVideoFrameDecoder.swift | 174 ++++- .../FeaturedStickerPacksController.swift | 2 +- TelegramUI/FileResources.swift | 4 +- TelegramUI/GalleryController.swift | 39 +- TelegramUI/GalleryControllerNode.swift | 22 +- .../GalleryControllerPresentationState.swift | 17 + TelegramUI/GalleryFooterContentNode.swift | 25 + TelegramUI/GalleryFooterNode.swift | 65 ++ TelegramUI/GalleryItemNode.swift | 4 + TelegramUI/GalleryPagerNode.swift | 24 +- TelegramUI/GeneratedMediaStoreSettings.swift | 53 ++ TelegramUI/GridMessageItem.swift | 2 +- TelegramUI/GroupAdminsController.swift | 4 +- TelegramUI/GroupInfoController.swift | 155 +++- TelegramUI/GroupsInCommonController.swift | 4 +- TelegramUI/HapticFeedback.swift | 5 +- ...rizontalStickersChatContextPanelNode.swift | 2 +- TelegramUI/ImageNode.swift | 38 +- .../InstalledStickerPacksController.swift | 2 +- TelegramUI/ItemListAvatarAndNameItem.swift | 93 ++- TelegramUI/ItemListController.swift | 62 +- TelegramUI/ItemListControllerNode.swift | 3 +- ...ItemListControllerSegmentedTitleView.swift | 46 ++ TelegramUI/ItemListDisclosureItem.swift | 41 +- TelegramUI/ItemListPeerItem.swift | 79 +- TelegramUI/ItemListStickerPackItem.swift | 4 +- TelegramUI/ItemListTextItem.swift | 3 +- TelegramUI/LegacyController.swift | 68 +- TelegramUI/LegacyControllerNode.swift | 6 +- TelegramUI/LegacyEmptyController.swift | 45 ++ TelegramUI/LegacyMediaPickers.swift | 2 +- TelegramUI/ManagedAudioPlaylistPlayer.swift | 2 +- TelegramUI/ManagedVideoNode.swift | 20 +- TelegramUI/Markdown.swift | 40 +- TelegramUI/MediaManager.swift | 7 +- ...MediaNavigationAccessoryItemListNode.swift | 3 +- TelegramUI/MediaPlayer.swift | 211 +++--- TelegramUI/MediaPlayerAudioRenderer.swift | 3 +- TelegramUI/MediaPlayerNode.swift | 269 +++++-- TelegramUI/MediaTrackFrame.swift | 6 +- TelegramUI/MediaTrackFrameBuffer.swift | 40 +- TelegramUI/MediaTrackFrameDecoder.swift | 1 + TelegramUI/MultiplexedSoftwareVideoNode.swift | 687 ++++++++++++++++++ ...ultiplexedSoftwareVideoSourceManager.swift | 115 +++ TelegramUI/MultiplexedVideoNode.swift | 503 +++++++++++++ TelegramUI/NavigateToChatController.swift | 4 +- TelegramUI/NetworkStatusTitleView.swift | 63 ++ TelegramUI/NetworkUsageStatsController.swift | 429 +++++++++++ .../NotificationContainerController.swift | 50 ++ .../NotificationContainerControllerNode.swift | 128 ++++ TelegramUI/NotificationItem.swift | 16 + .../NotificationItemContainerNode.swift | 122 ++++ TelegramUI/NotificationSoundSelection.swift | 2 +- TelegramUI/NotificationsAndSounds.swift | 2 +- TelegramUI/PasscodeOptionsController.swift | 417 +++++++++++ TelegramUI/PeerAvatar.swift | 12 +- TelegramUI/PeerAvatarImageGalleryItem.swift | 209 ++++++ TelegramUI/PeerMediaAudioPlaylist.swift | 2 +- .../PeerMediaCollectionController.swift | 10 +- TelegramUI/PeerSelectionController.swift | 4 +- TelegramUI/PeerSelectionControllerNode.swift | 3 +- TelegramUI/PhotoResources.swift | 384 ++++++---- TelegramUI/PreferencesKeys.swift | 12 +- .../PreparedChatHistoryViewTransition.swift | 7 +- TelegramUI/PresenceStrings.swift | 39 + TelegramUI/PresentationPasscodeSettings.swift | 65 ++ TelegramUI/PrivacyAndSecurityController.swift | 18 +- TelegramUI/RecentGifManagedMediaId.swift | 23 + TelegramUI/RecentSessionsController.swift | 2 +- TelegramUI/SampleBufferPool.swift | 50 ++ TelegramUI/SecretMediaPreviewController.swift | 4 +- TelegramUI/SecuritySettings.swift | 12 - .../SelectivePrivacySettingsController.swift | 2 +- ...ectivePrivacySettingsPeersController.swift | 4 +- TelegramUI/ServiceSoundManager.swift | 16 +- TelegramUI/SettingsController.swift | 162 ++++- TelegramUI/ShareActionButtonNode.swift | 62 ++ TelegramUI/ShareController.swift | 133 ++++ TelegramUI/ShareControllerNode.swift | 506 +++++++++++++ TelegramUI/ShareControllerPeerGridItem.swift | 168 +++++ .../SoftwareVideoLayerFrameManager.swift | 114 +++ TelegramUI/SoftwareVideoSource.swift | 225 ++++++ TelegramUI/SoftwareVideoThumbnailLayer.swift | 55 ++ TelegramUI/Sounds/notification.caf | Bin 0 -> 333780 bytes TelegramUI/StickerPackGalleryController.swift | 2 - TelegramUI/StickerPackPreviewController.swift | 12 +- .../StickerPackPreviewControllerNode.swift | 161 +++- TelegramUI/StickerPackPreviewGridItem.swift | 77 +- TelegramUI/StickerPreviewController.swift | 76 ++ TelegramUI/StickerPreviewControllerNode.swift | 147 ++++ TelegramUI/StickerResources.swift | 33 +- TelegramUI/StorageUsageController.swift | 377 ++++++++++ ...pLongTapOrDoubleTapGestureRecognizer.swift | 38 +- .../TelegramAccountAuxiliaryMethods.swift | 2 +- TelegramUI/TelegramApplicationContext.swift | 4 +- .../TransformOutgoingMessageMedia.swift | 4 +- ...pVerificationPasswordEntryController.swift | 2 +- .../TwoStepVerificationResetController.swift | 2 +- .../TwoStepVerificationUnlockController.swift | 2 +- TelegramUI/UserInfoController.swift | 67 +- TelegramUI/UsernameSetupController.swift | 2 +- TelegramUI/VideoPlayerProxy.swift | 115 +++ .../VoiceCallDataSavingController.swift | 150 ++++ TelegramUI/VoiceCallSettings.swift | 59 ++ .../ZoomableContentGalleryItemNode.swift | 34 +- 186 files changed, 11037 insertions(+), 826 deletions(-) create mode 100644 Images.xcassets/Chat List/LockLockedBottom.imageset/ChatListLock_LockedBottom@2x.png create mode 100644 Images.xcassets/Chat List/LockLockedBottom.imageset/Contents.json create mode 100644 Images.xcassets/Chat List/LockLockedTop.imageset/ChatListLock_LockedTop@2x.png create mode 100644 Images.xcassets/Chat List/LockLockedTop.imageset/Contents.json create mode 100644 Images.xcassets/Chat List/LockUnlockedBottom.imageset/ChatListLock_UnlockedBottom@2x.png create mode 100644 Images.xcassets/Chat List/LockUnlockedBottom.imageset/Contents.json create mode 100644 Images.xcassets/Chat List/LockUnlockedTop.imageset/ChatListLock_UnlockedTop@2x.png create mode 100644 Images.xcassets/Chat List/LockUnlockedTop.imageset/Contents.json create mode 100644 Images.xcassets/Chat/Input/Acessory Panels/MessageSelectionAction.imageset/ActionsWhiteIcon@2x.png create mode 100644 Images.xcassets/Chat/Input/Acessory Panels/MessageSelectionAction.imageset/ActionsWhiteIcon@3x.png create mode 100644 Images.xcassets/Chat/Input/Acessory Panels/MessageSelectionAction.imageset/Contents.json create mode 100644 Images.xcassets/Chat/Message/ShareIcon.imageset/Contents.json create mode 100644 Images.xcassets/Chat/Message/ShareIcon.imageset/ConversationChannelInlineShareIcon@2x.png create mode 100644 Images.xcassets/Chat/Message/ShareIcon.imageset/ConversationChannelInlineShareIcon@3x.png create mode 100644 TelegramUI/AutomaticMediaDownloadSettings.swift create mode 100644 TelegramUI/AvatarGalleryController.swift create mode 100644 TelegramUI/ChatItemGalleryFooterContentNode.swift create mode 100644 TelegramUI/ChatItemGalleryItemNode.swift create mode 100644 TelegramUI/ChatListTitleLockView.swift create mode 100644 TelegramUI/ChatMediaInputGifPane.swift create mode 100644 TelegramUI/ChatMediaInputRecentGifsItem.swift create mode 100644 TelegramUI/ChatMediaInputStickerPane.swift create mode 100644 TelegramUI/ChatMediaInputTrendingItem.swift create mode 100644 TelegramUI/ChatMediaInputTrendingPane.swift create mode 100644 TelegramUI/ChatMessageInstantVideoItemNode.swift create mode 100644 TelegramUI/ChatMessageNotificationItem.swift create mode 100644 TelegramUI/GalleryControllerPresentationState.swift create mode 100644 TelegramUI/GalleryFooterContentNode.swift create mode 100644 TelegramUI/GalleryFooterNode.swift create mode 100644 TelegramUI/GeneratedMediaStoreSettings.swift create mode 100644 TelegramUI/ItemListControllerSegmentedTitleView.swift create mode 100644 TelegramUI/MultiplexedSoftwareVideoNode.swift create mode 100644 TelegramUI/MultiplexedSoftwareVideoSourceManager.swift create mode 100644 TelegramUI/MultiplexedVideoNode.swift create mode 100644 TelegramUI/NetworkUsageStatsController.swift create mode 100644 TelegramUI/NotificationContainerController.swift create mode 100644 TelegramUI/NotificationContainerControllerNode.swift create mode 100644 TelegramUI/NotificationItem.swift create mode 100644 TelegramUI/NotificationItemContainerNode.swift create mode 100644 TelegramUI/PasscodeOptionsController.swift create mode 100644 TelegramUI/PeerAvatarImageGalleryItem.swift create mode 100644 TelegramUI/PresentationPasscodeSettings.swift create mode 100644 TelegramUI/RecentGifManagedMediaId.swift create mode 100644 TelegramUI/SampleBufferPool.swift delete mode 100644 TelegramUI/SecuritySettings.swift create mode 100644 TelegramUI/ShareActionButtonNode.swift create mode 100644 TelegramUI/ShareController.swift create mode 100644 TelegramUI/ShareControllerNode.swift create mode 100644 TelegramUI/ShareControllerPeerGridItem.swift create mode 100644 TelegramUI/SoftwareVideoLayerFrameManager.swift create mode 100644 TelegramUI/SoftwareVideoSource.swift create mode 100644 TelegramUI/SoftwareVideoThumbnailLayer.swift create mode 100644 TelegramUI/Sounds/notification.caf delete mode 100644 TelegramUI/StickerPackGalleryController.swift create mode 100644 TelegramUI/StickerPreviewController.swift create mode 100644 TelegramUI/StickerPreviewControllerNode.swift create mode 100644 TelegramUI/StorageUsageController.swift create mode 100644 TelegramUI/VoiceCallDataSavingController.swift create mode 100644 TelegramUI/VoiceCallSettings.swift 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 0000000000000000000000000000000000000000..6c6bbe3e6be17afe628acb3ead6e7c73efd40902 GIT binary patch literal 106 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W?!3HGtkJx+wQih%`jv*C{$q5-1Dn|o;ml!+W zz5IVl!UV~L%@=qU-w6dVcWs&t@MPtbMst|n{hOcX;`j@94vIJ^o@O1TaS?83{ F1OW6XA`$=q literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9bdcc22b162055167faf42a5615b04c0425336eb GIT binary patch literal 177 zcmeAS@N?(olHy`uVBq!ia0vp^d_c^_!3HE-Zss2cQWc&qjv*C{$qtOX{yY+|=B;CR zB<=7=zGdMX(F9AL3k6eq7yf&h(spZky+W6vp$Fd*wl!jG4NcOHla3^8h!S8ZI5M literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..16c40761e0839ec4ac8756e0ea77ded7df643b52 GIT binary patch literal 95 zcmeAS@N?(olHy`uVBq!ia0vp^B0$W?!2%>(&wi-}Qfi(qjv*T7lM^N+-8l1MKEoZJ t5Z4x?j-W+T8Xv51s?t`m;_CDfVd&KtWKnBK>;mdw@O1TaS?83{1ORl68qfd$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e7b1c740330343ca2f844afff43af99fef45b44c GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^d_c^_!2%@pE-d!}Qt_THjv*C{Yfo+$Z7~pFxF{cZ zzRcMrI6YcnDML`!g6`srmAl{PGMg)}T{2PSOPas`<~W5+ox3(u&AoXILiBhqgcVdq zub*bPellOlG9BgRd)PcvZhrRZ&Z_@+r|$GO*0X})Rbrgew*#$W@O1TaS?83{1OQpX BG#LN@ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2ad26719c3da12f6b5a46ab267a50e0eb1a3768c GIT binary patch literal 210 zcmeAS@N?(olHy`uVBq!ia0vp^YCvqt!2%@P%K`;})J#to$B+ufx93^;4mk+4KHSMv zW}MV(+9Z5XX@@M|Zoy9#H(WlPbt(V%>(um+I?m*tqiR8CS`PE{A5)vcxcrL4`-d{q z5?*}l{dg}}Z$cK=?j)YG3h%sRCvXM#Ec|2M{%xw)m)S3?yO8bg K=d#Wzp$P!X>s8|b literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..98dc6d8d62a499dc3b536934ef58dc36e42a63c2 GIT binary patch literal 628 zcmeAS@N?(olHy`uVBq!ia0vp^mO$*!!3HD)lXD}16k~CayA#8@b22X(7?|`tT^vIy z7~e*C2OT!x$<6S;VA`H|wn_Vk(wiF_Tcr(ZuIWS+GwB@&lu+BV2=CcL|yY_5IW9D{qw7uGd@C@Fv0X;M(OvdUxOR-L>ju z+R^-1S;FJt)pbYft=vilSrgc;y*BixRLm2ub(5*ReOlt6;-pXkT}2DGAk&LMO{yx* z=arL|z1`w1?y)Xm@BIXJt6a@bUn=jl$NPP;`z|QWd`9Y<)PeHf56UzuKYZS)e&!D~oiKc1n4^`gFzU{*)_=SVY@3J=ZRn_H)@1G1V0o zKAu^uThExQZq;x+n&=gzy&`p==1NyiExzDMQI5KweLZ!(0t#nLy26z;bH5Px$VM#AR z7u-a2?xZfo_#E7EUwX+Ush1C4ntXZR@5#}FwkTF8W6Ur$ww4;G{71m_irM%WkP}o^ zUKoHHloz%H$_I0R^1wWx{$U-U|G`Lapks80p3ocWs9B zB(~iRW6C-Dj-3|pxnYDZ(QhD}daTzAQKZk<-iucMOCVclo_f^?zqW2m;2|QEYH2}s zn6OBl67XA)`a8Pf@dnl-Hk&V&Q}l}FXn}Uo82Rc}DW$DLZl%!Y((TH(Zh{U`>jL!T z&jeemY(P!JvTxapd0{`Q&t0FK`#^3;IfT$n1^VIh0x8#U*~8-kY3XUL2|c0@wAVWH zH1M7xS=wW5+PI(5cGC&3@XIIC;RFss2T?|05+E(w{Vyv}UPyUuK*@g~{{U5l(qe9? RRmuPW002ovPDHLkV1mMN!>s@S literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..600f3a10be6565dee609203f7f8c6b52dd1cb11e GIT binary patch literal 622 zcmV-!0+IcRP)Px%C`m*?R9Fe+mOU>7Q51%E5i}wZA|zP7_=r{^LZMJltZbq43v{9re?cWt`3(vo z@ll9MA{rsFB%(se5?Rk#vy+|KYv#_*oomBM-ktk-&NFAu-I-MCKUuL@%%O~ldu}k{ z#OFi64ov_i0Vk{jCIFLyGXj_-Tx`Ik;bH;WfU6nM7F6v<#j2=)buzw> zMpW~b#*UPk18x_9SxXSAFr~~@O#rrxe$&)72a){SU!t}uYC8kpwdTAr{O{JadI6JM zxHU)Cdg$Mx7MZYYo{W`aS7vU7!MXtxz*6SOS|ja8)Fp!<_QCu)Iz?CL#gU9lt>(}o z(lKSmJR5$J|rtbK!QIoL1Hy@iso`ZlMjnxgNYzv3lVd zE4$}5?-jRt#+ZCKMl$wJQ9*0Y)M3;$BNVtN;K207*qo IM6N<$f)XthHUIzs literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f3f16ae66fa6db678d3ae08157ecc7e8a07d6d87 GIT binary patch literal 333780 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 Dp;K8u literal 0 HcmV?d00001 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 + } + } } }